feat: Add Tabler UI support and enhance frontend with new features
Publish Tabler Docker image / build-and-push-tabler (push) Failing after 6m47s
Publish Tabler Docker image / build-and-push-tabler (push) Failing after 6m47s
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
name: Publish Tabler Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- tabler
|
||||
|
||||
jobs:
|
||||
build-and-push-tabler:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
- name: Build and push Tabler Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
git.shts.su/${{ gitea.repository }}:tabler
|
||||
git.shts.su/${{ gitea.repository }}:tabler-${{ gitea.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=S3 Lists Manager with Tabler UI
|
||||
org.opencontainers.image.description=Modern S3 Lists Manager with Tabler UI design
|
||||
org.opencontainers.image.version=tabler
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
@@ -28,4 +28,10 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
git.shts.su/${{ gitea.repository }}:latest
|
||||
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
|
||||
git.shts.su/${{ gitea.repository }}:${{ gitea.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=S3 Lists Manager
|
||||
org.opencontainers.image.description=S3 Lists Manager for domains and AS management
|
||||
org.opencontainers.image.version=latest
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
@@ -0,0 +1,208 @@
|
||||
# Docker Images
|
||||
|
||||
Этот проект предоставляет Docker образы для S3 Lists Manager с двумя различными версиями интерфейса.
|
||||
|
||||
## 🏷️ Доступные теги
|
||||
|
||||
### Основная версия (main branch)
|
||||
- `git.shts.su/[repository]:latest` - Стабильная версия с базовым интерфейсом
|
||||
- `git.shts.su/[repository]:[commit-sha]` - Версия с конкретным коммитом
|
||||
|
||||
### Tabler версия (tabler branch)
|
||||
- `git.shts.su/[repository]:tabler` - Версия с современным Tabler UI
|
||||
- `git.shts.su/[repository]:tabler-[commit-sha]` - Tabler версия с конкретным коммитом
|
||||
|
||||
## 🚀 Запуск
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
docker pull git.shts.su/[repository]:latest
|
||||
docker stop s3-lists-manager
|
||||
docker rm s3-lists-manager
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
docker pull git.shts.su/[repository]:tabler
|
||||
docker stop s3-lists-manager-tabler
|
||||
docker rm s3-lists-manager-tabler
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
Создайте файл `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
s3-lists-manager:
|
||||
image: git.shts.su/[repository]:latest
|
||||
container_name: s3-lists-manager
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
s3-lists-manager-tabler:
|
||||
image: git.shts.su/[repository]:tabler
|
||||
container_name: s3-lists-manager-tabler
|
||||
ports:
|
||||
- "3002:3001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
```
|
||||
|
||||
Запуск:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 🔧 Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `NODE_ENV` | Окружение Node.js | `production` |
|
||||
| `PORT` | Порт для запуска сервера | `3001` |
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Просмотр логов
|
||||
```bash
|
||||
# Основная версия
|
||||
docker logs s3-lists-manager
|
||||
|
||||
# Tabler версия
|
||||
docker logs s3-lists-manager-tabler
|
||||
```
|
||||
|
||||
### Статистика контейнеров
|
||||
```bash
|
||||
docker stats s3-lists-manager s3-lists-manager-tabler
|
||||
```
|
||||
|
||||
## 🔍 Отладка
|
||||
|
||||
### Вход в контейнер
|
||||
```bash
|
||||
# Основная версия
|
||||
docker exec -it s3-lists-manager /bin/bash
|
||||
|
||||
# Tabler версия
|
||||
docker exec -it s3-lists-manager-tabler /bin/bash
|
||||
```
|
||||
|
||||
### Проверка процессов
|
||||
```bash
|
||||
docker exec s3-lists-manager ps aux
|
||||
```
|
||||
|
||||
## 🏗️ Сборка локально
|
||||
|
||||
### Основная версия
|
||||
```bash
|
||||
git checkout main
|
||||
docker build -t s3-lists-manager:local .
|
||||
```
|
||||
|
||||
### Tabler версия
|
||||
```bash
|
||||
git checkout tabler
|
||||
docker build -t s3-lists-manager-tabler:local .
|
||||
```
|
||||
|
||||
## 📝 CI/CD
|
||||
|
||||
### Автоматическая сборка
|
||||
|
||||
- **Ветка `main`**: Автоматически создает образ с тегом `latest`
|
||||
- **Ветка `tabler`**: Автоматически создает образ с тегом `tabler`
|
||||
|
||||
### Workflow файлы
|
||||
|
||||
- `.gitea/workflows/docker-publish.yml` - Сборка основной версии
|
||||
- `.gitea/workflows/docker-publish-tabler.yml` - Сборка Tabler версии
|
||||
|
||||
## 🔒 Безопасность
|
||||
|
||||
### Проверка образа
|
||||
```bash
|
||||
docker scan git.shts.su/[repository]:latest
|
||||
docker scan git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
### Запуск в изолированной сети
|
||||
```bash
|
||||
docker network create s3-lists-network
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
--network s3-lists-network \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
## 📈 Производительность
|
||||
|
||||
### Ограничение ресурсов
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
--memory=512m \
|
||||
--cpus=0.5 \
|
||||
-p 3001:3001 \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
### Мониторинг ресурсов
|
||||
```bash
|
||||
docker stats --no-stream s3-lists-manager
|
||||
```
|
||||
|
||||
## 🆘 Устранение неполадок
|
||||
|
||||
### Проверка статуса контейнера
|
||||
```bash
|
||||
docker ps -a | grep s3-lists-manager
|
||||
```
|
||||
|
||||
### Перезапуск контейнера
|
||||
```bash
|
||||
docker restart s3-lists-manager
|
||||
```
|
||||
|
||||
### Очистка неиспользуемых образов
|
||||
```bash
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Примечание**: Замените `[repository]` на актуальное имя вашего репозитория в Gitea.
|
||||
@@ -12,6 +12,7 @@
|
||||
- **Сохранение в S3:** Все изменения сохраняются непосредственно в файлах в бакете Yandex Cloud.
|
||||
- **Docker-контейнеризация:** Готовый `Dockerfile` для сборки и запуска приложения в изолированном окружении.
|
||||
- **CI/CD с Gitea Actions:** Автоматическая сборка и публикация Docker-образа в Gitea Registry при пуше в `main`.
|
||||
- **Два варианта интерфейса:** Базовая версия и современная версия с Tabler UI.
|
||||
|
||||
## 🛠️ Технологический стек
|
||||
|
||||
@@ -19,6 +20,7 @@
|
||||
|--------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| **Фронтенд** | [**React**](https://reactjs.org/) + [**Vite**](https://vitejs.dev/) |
|
||||
| | [**React-Bootstrap**](https://react-bootstrap.github.io/) (UI-компоненты) |
|
||||
| | [**Tabler Icons**](https://tabler-icons.io/) (иконки для Tabler версии) |
|
||||
| | [**Axios**](https://axios-http.com/) (HTTP-клиент) |
|
||||
| **Бэкенд** | [**Node.js**](https://nodejs.org/) + [**Express**](https://expressjs.com/) |
|
||||
| | [**AWS SDK for JS**](https://aws.amazon.com/sdk-for-javascript/) (для работы с Yandex Cloud S3) |
|
||||
@@ -103,9 +105,41 @@ graph TD
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Приложение полностью готово к запуску в Docker.
|
||||
Приложение полностью готово к запуску в Docker с двумя вариантами интерфейса.
|
||||
|
||||
### Сборка образа
|
||||
### Доступные образы
|
||||
|
||||
#### Основная версия (main branch)
|
||||
```bash
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
#### Tabler версия (tabler branch)
|
||||
```bash
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
### Быстрый запуск
|
||||
|
||||
#### Основная версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager \
|
||||
-p 3001:3001 \
|
||||
--env-file ./backend/.env \
|
||||
git.shts.su/[repository]:latest
|
||||
```
|
||||
|
||||
#### Tabler версия
|
||||
```bash
|
||||
docker run -d \
|
||||
--name s3-lists-manager-tabler \
|
||||
-p 3002:3001 \
|
||||
--env-file ./backend/.env \
|
||||
git.shts.su/[repository]:tabler
|
||||
```
|
||||
|
||||
### Локальная сборка
|
||||
|
||||
Для сборки образа выполните команду в корневой директории проекта:
|
||||
```bash
|
||||
@@ -122,6 +156,40 @@ docker run --rm -p 3001:3001 --env-file ./backend/.env s3-lists-manager
|
||||
|
||||
После этого приложение будет доступно по адресу `http://localhost:3001`.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Создайте файл `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
s3-lists-manager:
|
||||
image: git.shts.su/[repository]:latest
|
||||
container_name: s3-lists-manager
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
|
||||
s3-lists-manager-tabler:
|
||||
image: git.shts.su/[repository]:tabler
|
||||
container_name: s3-lists-manager-tabler
|
||||
ports:
|
||||
- "3002:3001"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
```
|
||||
|
||||
Запуск:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Подробная документация по Docker образам доступна в файле [DOCKER.md](DOCKER.md).
|
||||
|
||||
## ⚙️ API Endpoints
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|
||||
+170
-7
@@ -1,12 +1,175 @@
|
||||
# React + Vite
|
||||
# S3 Lists Manager - Frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
Современный веб-интерфейс для управления списками доменов и AS с использованием Tabler UI.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## 🎨 Особенности
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
- **Современный UI/UX** - Полностью переработанный интерфейс в стиле Tabler
|
||||
- **Адаптивный дизайн** - Отлично работает на всех устройствах
|
||||
- **Иконки Tabler** - Красивые иконки для улучшения пользовательского опыта
|
||||
- **Интерактивные элементы** - Анимации, hover-эффекты, модальные окна
|
||||
- **Статистика** - Отображение количества элементов в реальном времени
|
||||
- **Поиск** - Быстрый поиск по спискам
|
||||
- **Массовые операции** - Возможность массового обновления шлюзов
|
||||
- **Подтверждения** - Модальные окна для подтверждения важных действий
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## 🚀 Технологии
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
- **React 19** - Современная библиотека для создания пользовательских интерфейсов
|
||||
- **Tabler Icons** - Красивые иконки в стиле Tabler
|
||||
- **CSS Variables** - Современные CSS переменные для легкой кастомизации
|
||||
- **Responsive Design** - Адаптивный дизайн для всех устройств
|
||||
- **Vite** - Быстрый сборщик проекта
|
||||
|
||||
## 📦 Установка
|
||||
|
||||
1. Убедитесь, что у вас установлен Node.js (версия 16 или выше)
|
||||
|
||||
2. Перейдите в папку frontend:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
3. Установите зависимости:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Запустите проект в режиме разработки:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Откройте браузер и перейдите по адресу: `http://localhost:5173`
|
||||
|
||||
## 🏗️ Сборка для продакшена
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Собранные файлы будут находиться в папке `dist/`.
|
||||
|
||||
## 🎯 Использование
|
||||
|
||||
### Управление доменами
|
||||
|
||||
1. **Добавление домена**:
|
||||
- Введите имя домена (например: `example.com`)
|
||||
- Выберите шлюз (по умолчанию: `SharkFIN`)
|
||||
- Нажмите "Добавить"
|
||||
|
||||
2. **Редактирование**:
|
||||
- Нажмите на иконку редактирования рядом с доменом
|
||||
- Измените шлюз
|
||||
- Нажмите "Сохранить" или "Отмена"
|
||||
|
||||
3. **Удаление**:
|
||||
- Нажмите на иконку удаления
|
||||
- Подтвердите удаление в модальном окне
|
||||
|
||||
### Управление AS
|
||||
|
||||
Аналогично управлению доменами, но для AS номеров (например: `AS12345`).
|
||||
|
||||
### Массовые операции
|
||||
|
||||
1. Введите текущий шлюз в поле "Заменить с"
|
||||
2. Введите новый шлюз в поле "Заменить на"
|
||||
3. Нажмите "Выполнить замену"
|
||||
4. Подтвердите операцию
|
||||
|
||||
### Сохранение изменений
|
||||
|
||||
После внесения изменений обязательно нажмите "Сохранить в S3" для записи изменений в базу данных.
|
||||
|
||||
## 🎨 Кастомизация
|
||||
|
||||
### Цветовая схема
|
||||
|
||||
Основные цвета можно изменить в CSS переменных в файле `src/App.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--tblr-primary: #206bc4; /* Основной цвет */
|
||||
--tblr-success: #2fb344; /* Цвет успеха */
|
||||
--tblr-warning: #f59f00; /* Цвет предупреждения */
|
||||
--tblr-danger: #d63939; /* Цвет ошибки */
|
||||
}
|
||||
```
|
||||
|
||||
### Шрифты
|
||||
|
||||
Проект использует шрифт Inter. Для изменения шрифта обновите переменную:
|
||||
|
||||
```css
|
||||
--tblr-font-family-sans-serif: "Ваш шрифт", -apple-system, BlinkMacSystemFont, ...;
|
||||
```
|
||||
|
||||
## 📱 Адаптивность
|
||||
|
||||
Интерфейс полностью адаптивен и оптимизирован для:
|
||||
|
||||
- **Desktop** (1200px+) - Полный интерфейс с боковой панелью
|
||||
- **Tablet** (768px - 1199px) - Адаптированный интерфейс
|
||||
- **Mobile** (до 767px) - Мобильная версия с выпадающим меню
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.jsx # Главный компонент приложения
|
||||
├── App.css # Стили Tabler
|
||||
├── DataManager.jsx # Компонент управления данными
|
||||
├── main.jsx # Точка входа
|
||||
└── index.css # Базовые стили
|
||||
```
|
||||
|
||||
### Добавление новых функций
|
||||
|
||||
1. Создайте новый компонент в папке `src/`
|
||||
2. Импортируйте необходимые иконки из `@tabler/icons-react`
|
||||
3. Используйте CSS классы Tabler для стилизации
|
||||
4. Добавьте адаптивность для мобильных устройств
|
||||
|
||||
### Иконки
|
||||
|
||||
Все иконки импортируются из `@tabler/icons-react`:
|
||||
|
||||
```jsx
|
||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
|
||||
// Использование
|
||||
<IconPlus className="icon" />
|
||||
```
|
||||
|
||||
## 🐛 Отладка
|
||||
|
||||
### Консоль разработчика
|
||||
|
||||
Откройте консоль разработчика (F12) для просмотра ошибок и логов.
|
||||
|
||||
### Проверка API
|
||||
|
||||
Убедитесь, что бэкенд запущен и доступен по адресу `/api`.
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Этот проект использует лицензию MIT.
|
||||
|
||||
## 🤝 Вклад в проект
|
||||
|
||||
1. Форкните репозиторий
|
||||
2. Создайте ветку для новой функции
|
||||
3. Внесите изменения
|
||||
4. Создайте Pull Request
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
Если у вас есть вопросы или проблемы, создайте Issue в репозитории.
|
||||
|
||||
---
|
||||
|
||||
**S3 Lists Manager** - Современный интерфейс для управления списками с использованием Tabler UI.
|
||||
|
||||
+10
-2
@@ -1,10 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
<meta name="description" content="S3 Lists Manager - Управление списками доменов и AS" />
|
||||
<meta name="theme-color" content="#206bc4" />
|
||||
|
||||
<!-- Inter Font -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<title>S3 Lists Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+27
@@ -8,6 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"axios": "^1.10.0",
|
||||
"bootstrap": "^5.3.7",
|
||||
"react": "^19.1.0",
|
||||
@@ -641,6 +642,32 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.0.tgz",
|
||||
"integrity": "sha512-jtVqv0JC1WU2TTEBN32D9+R6mc1iEBuPwLnBsWaR02SIEciu9aq5806AWkCHuObhQ4ERhhXErLEK7Fs+tEZxiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.34.0.tgz",
|
||||
"integrity": "sha512-OpEIR2iZsIXECtAIMbn1zfKfQ3zKJjXyIZlkgOGUL9UkMCFycEiF2Y8AVfEQsyre/3FnBdlWJvGr0NU47n2TbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.34.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"axios": "^1.10.0",
|
||||
"bootstrap": "^5.3.7",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -40,3 +40,932 @@
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Tabler CSS Variables */
|
||||
:root {
|
||||
--tblr-primary: #206bc4;
|
||||
--tblr-primary-rgb: 32, 107, 196;
|
||||
--tblr-secondary: #6c757d;
|
||||
--tblr-secondary-rgb: 108, 117, 125;
|
||||
--tblr-success: #2fb344;
|
||||
--tblr-success-rgb: 47, 179, 68;
|
||||
--tblr-info: #467fd0;
|
||||
--tblr-info-rgb: 70, 127, 208;
|
||||
--tblr-warning: #f59f00;
|
||||
--tblr-warning-rgb: 245, 159, 0;
|
||||
--tblr-danger: #d63939;
|
||||
--tblr-danger-rgb: 214, 57, 57;
|
||||
--tblr-light: #f8f9fa;
|
||||
--tblr-light-rgb: 248, 249, 250;
|
||||
--tblr-dark: #1e293b;
|
||||
--tblr-dark-rgb: 30, 41, 59;
|
||||
--tblr-body-bg: #f5f6f7;
|
||||
--tblr-body-color: #1e293b;
|
||||
--tblr-border-color: #e9ecef;
|
||||
--tblr-border-radius: 0.375rem;
|
||||
--tblr-border-radius-sm: 0.25rem;
|
||||
--tblr-border-radius-lg: 0.5rem;
|
||||
--tblr-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--tblr-box-shadow-sm: 0 0.0625rem 0.125rem rgba(0, 0, 0, 0.075);
|
||||
--tblr-box-shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--tblr-font-family-sans-serif: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--tblr-font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--tblr-font-size-base: 0.875rem;
|
||||
--tblr-line-height-base: 1.5;
|
||||
--tblr-heading-font-weight: 600;
|
||||
--tblr-btn-font-weight: 500;
|
||||
--tblr-btn-border-radius: 0.375rem;
|
||||
--tblr-btn-padding-x: 0.75rem;
|
||||
--tblr-btn-padding-y: 0.375rem;
|
||||
--tblr-input-border-radius: 0.375rem;
|
||||
--tblr-input-padding-x: 0.75rem;
|
||||
--tblr-input-padding-y: 0.375rem;
|
||||
--tblr-card-border-radius: 0.5rem;
|
||||
--tblr-card-border-width: 0;
|
||||
--tblr-card-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--tblr-navbar-height: 3.5rem;
|
||||
--tblr-sidebar-width: 16rem;
|
||||
--tblr-sidebar-width-md: 12rem;
|
||||
--tblr-sidebar-width-sm: 4rem;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--tblr-font-family-sans-serif);
|
||||
font-size: var(--tblr-font-size-base);
|
||||
line-height: var(--tblr-line-height-base);
|
||||
color: var(--tblr-body-color);
|
||||
background-color: var(--tblr-body-bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Page Layout */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--tblr-border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--tblr-heading-font-weight);
|
||||
margin: 0;
|
||||
color: var(--tblr-dark);
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid var(--tblr-border-color);
|
||||
box-shadow: var(--tblr-box-shadow-sm);
|
||||
height: var(--tblr-navbar-height);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: var(--tblr-heading-font-weight);
|
||||
font-size: 1.25rem;
|
||||
color: var(--tblr-dark);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: var(--tblr-primary);
|
||||
}
|
||||
|
||||
.navbar-brand-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.5rem;
|
||||
color: var(--tblr-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-toggler:hover {
|
||||
color: var(--tblr-dark);
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--tblr-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--tblr-border-radius);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--tblr-dark);
|
||||
background-color: var(--tblr-light);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--tblr-primary);
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.nav-link-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.avatar {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border: var(--tblr-card-border-width) solid var(--tblr-border-color);
|
||||
border-radius: var(--tblr-card-border-radius);
|
||||
box-shadow: var(--tblr-card-box-shadow);
|
||||
margin-bottom: 1.5rem;
|
||||
transition: box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--tblr-box-shadow-lg);
|
||||
}
|
||||
|
||||
.card-md {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--tblr-border-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--tblr-heading-font-weight);
|
||||
margin: 0;
|
||||
color: var(--tblr-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.h3 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: var(--tblr-heading-font-weight);
|
||||
margin: 0;
|
||||
color: var(--tblr-dark);
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--tblr-btn-padding-y) var(--tblr-btn-padding-x);
|
||||
font-size: var(--tblr-font-size-base);
|
||||
font-weight: var(--tblr-btn-font-weight);
|
||||
line-height: 1.5;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--tblr-btn-border-radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease-in-out;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--tblr-box-shadow);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--tblr-primary);
|
||||
border-color: var(--tblr-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1a5aa3;
|
||||
border-color: #1a5aa3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--tblr-secondary);
|
||||
border-color: var(--tblr-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #5a6268;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--tblr-success);
|
||||
border-color: var(--tblr-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #28a745;
|
||||
border-color: #28a745;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--tblr-warning);
|
||||
border-color: var(--tblr-warning);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #e0a800;
|
||||
border-color: #e0a800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--tblr-danger);
|
||||
border-color: var(--tblr-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #c82333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
background-color: transparent;
|
||||
border-color: var(--tblr-primary);
|
||||
color: var(--tblr-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--tblr-primary);
|
||||
border-color: var(--tblr-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--tblr-secondary);
|
||||
color: var(--tblr-secondary);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--tblr-secondary);
|
||||
border-color: var(--tblr-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
background-color: transparent;
|
||||
border-color: var(--tblr-danger);
|
||||
color: var(--tblr-danger);
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
background-color: var(--tblr-danger);
|
||||
border-color: var(--tblr-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-list {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: var(--tblr-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--tblr-input-padding-y) var(--tblr-input-padding-x);
|
||||
font-size: var(--tblr-font-size-base);
|
||||
line-height: var(--tblr-line-height-base);
|
||||
color: var(--tblr-body-color);
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--tblr-border-color);
|
||||
border-radius: var(--tblr-input-border-radius);
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--tblr-primary);
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--tblr-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.form-control-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--tblr-border-color);
|
||||
}
|
||||
|
||||
/* Input Icon */
|
||||
.input-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon .icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--tblr-secondary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-icon .form-control {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--tblr-body-color);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
vertical-align: top;
|
||||
border-top: 1px solid var(--tblr-border-color);
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
vertical-align: bottom;
|
||||
border-bottom: 2px solid var(--tblr-border-color);
|
||||
font-weight: 600;
|
||||
color: var(--tblr-dark);
|
||||
background-color: var(--tblr-light);
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
.table-vcenter td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-table th:first-child,
|
||||
.card-table td:first-child {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.card-table th:last-child,
|
||||
.card-table td:last-child {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
border-radius: var(--tblr-border-radius-sm);
|
||||
}
|
||||
|
||||
.bg-blue-lt {
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.1);
|
||||
color: var(--tblr-primary);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
position: relative;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--tblr-border-radius);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
color: #721c24;
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
color: #155724;
|
||||
background-color: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-dismissible {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 1rem 1.5rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-close::before {
|
||||
content: "×";
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.btn-close:hover::before {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-icon .icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
color: var(--tblr-secondary);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--tblr-heading-font-weight);
|
||||
color: var(--tblr-dark);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: var(--tblr-font-size-base);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
margin: 1.75rem auto;
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fff;
|
||||
border-radius: var(--tblr-card-border-radius);
|
||||
box-shadow: var(--tblr-box-shadow-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--tblr-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--tblr-heading-font-weight);
|
||||
margin: 0;
|
||||
color: var(--tblr-dark);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--tblr-border-color);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-muted {
|
||||
color: var(--tblr-secondary) !important;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.font-weight-medium {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.w-1 {
|
||||
width: 1rem !important;
|
||||
}
|
||||
|
||||
.me-2 {
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem !important;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 1rem !important;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1.5rem !important;
|
||||
padding-bottom: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: transparent;
|
||||
border-top: 1px solid var(--tblr-border-color);
|
||||
padding: 1rem 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.list-inline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-inline-item {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.list-inline-item:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.list-inline-dots .list-inline-item:not(:last-child)::after {
|
||||
content: "·";
|
||||
margin-left: 0.5rem;
|
||||
color: var(--tblr-secondary);
|
||||
}
|
||||
|
||||
.link-secondary {
|
||||
color: var(--tblr-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-secondary:hover {
|
||||
color: var(--tblr-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.navbar-expand-md .navbar-collapse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-expand-md.show .navbar-collapse {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid var(--tblr-border-color);
|
||||
box-shadow: var(--tblr-box-shadow);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-expand-md .navbar-nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar-expand-md .nav-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-expand-md .nav-link {
|
||||
padding: 1rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container-xl {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Row and Col */
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -0.75rem;
|
||||
}
|
||||
|
||||
.col,
|
||||
.col-lg-4,
|
||||
.col-lg-8 {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-lg-4 {
|
||||
flex: 0 0 33.333333%;
|
||||
max-width: 33.333333%;
|
||||
}
|
||||
|
||||
.col-lg-8 {
|
||||
flex: 0 0 66.666667%;
|
||||
max-width: 66.666667%;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.col-lg-4,
|
||||
.col-lg-8 {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner-border {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0.25em solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spinner-border 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-border {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--tblr-primary) !important;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* Enhanced hover effects */
|
||||
.card:hover .card-title {
|
||||
color: var(--tblr-primary);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--tblr-box-shadow);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--tblr-primary-rgb), 0.25), var(--tblr-box-shadow);
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.btn:disabled {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
.btn:focus,
|
||||
.form-control:focus,
|
||||
.nav-link:focus {
|
||||
outline: 2px solid var(--tblr-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark mode support (future enhancement) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--tblr-body-bg: #1a1a1a;
|
||||
--tblr-body-color: #e9ecef;
|
||||
--tblr-dark: #f8f9fa;
|
||||
--tblr-border-color: #2d3748;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: #4a5568;
|
||||
border-color: #718096;
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: #e9ecef;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background-color: #4a5568;
|
||||
color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
+141
-32
@@ -1,40 +1,149 @@
|
||||
import { useState } from 'react';
|
||||
import { Container, Navbar, Tabs, Tab } from 'react-bootstrap';
|
||||
import {
|
||||
IconBrandTabler,
|
||||
IconWorld,
|
||||
IconNetwork,
|
||||
IconSettings,
|
||||
IconMenu2,
|
||||
IconX
|
||||
} from '@tabler/icons-react';
|
||||
import DataManager from './DataManager';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<Navbar bg="dark" variant="dark" expand="lg">
|
||||
<Container>
|
||||
<Navbar.Brand href="#">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" className="d-inline-block align-top me-2" viewBox="0 0 16 16">
|
||||
<path d="M4.928 1.428a.5.5 0 0 0-.01.02H2.5A1.5 1.5 0 0 0 1 3v1.5a.5.5 0 0 0 1 0V3a.5.5 0 0 1 .5-.5h2.418a.5.5 0 0 0 .01-.02zM7.5 2.5a.5.5 0 0 1 .5-.5h3.5a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1h-3a.5.5 0 0 1-.5-.5zM.5 5a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 1 .5-.5zM2 7.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0 2a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M1.5 14a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-.5-.5zM3 14.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zM13.5 14a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-.5-.5z"/>
|
||||
</svg>
|
||||
S3 Lists Manager
|
||||
</Navbar.Brand>
|
||||
</Container>
|
||||
</Navbar>
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('domains');
|
||||
|
||||
<Container className="mt-4">
|
||||
<Tabs defaultActiveKey="domains" id="main-tabs" className="mb-3" fill>
|
||||
<Tab eventKey="domains" title="Домены">
|
||||
<DataManager
|
||||
entityName="домен"
|
||||
entityKey="domains"
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="asns" title="AS">
|
||||
<DataManager
|
||||
entityName="AS"
|
||||
entityKey="asns"
|
||||
placeholder="AS12345"
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Container>
|
||||
</>
|
||||
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Header */}
|
||||
<header className="navbar navbar-expand-md navbar-light d-print-none">
|
||||
<div className="container-xl">
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<IconMenu2 className="icon" />
|
||||
</button>
|
||||
<h1 className="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
||||
<IconBrandTabler className="navbar-brand-icon me-2" />
|
||||
S3 Lists Manager
|
||||
</h1>
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
<div className="nav-item dropdown">
|
||||
<a href="#" className="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
|
||||
<span className="avatar avatar-sm" style={{backgroundImage: 'url(./static/avatars/000m.jpg)'}}></span>
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
|
||||
<a href="#" className="dropdown-item">Profile</a>
|
||||
<a href="#" className="dropdown-item">Settings</a>
|
||||
<div className="dropdown-divider"></div>
|
||||
<a href="#" className="dropdown-item">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`navbar-expand-md ${sidebarOpen ? 'show' : ''}`}>
|
||||
<div className="collapse navbar-collapse" id="navbar-menu">
|
||||
<div className="navbar navbar-light">
|
||||
<div className="container-xl">
|
||||
<ul className="navbar-nav">
|
||||
<li className="nav-item">
|
||||
<a
|
||||
className={`nav-link ${activeTab === 'domains' ? 'active' : ''}`}
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); setActiveTab('domains'); }}
|
||||
>
|
||||
<IconWorld className="nav-link-icon" />
|
||||
<span className="nav-link-title">Домены</span>
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a
|
||||
className={`nav-link ${activeTab === 'asns' ? 'active' : ''}`}
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); setActiveTab('asns'); }}
|
||||
>
|
||||
<IconNetwork className="nav-link-icon" />
|
||||
<span className="nav-link-title">AS</span>
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className="nav-link" href="#">
|
||||
<IconSettings className="nav-link-icon" />
|
||||
<span className="nav-link-title">Настройки</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="page-wrapper">
|
||||
<div className="container-xl">
|
||||
<div className="page-header d-print-none">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h2 className="page-title">
|
||||
{activeTab === 'domains' ? 'Управление доменами' : 'Управление AS'}
|
||||
</h2>
|
||||
<div className="text-muted mt-1">
|
||||
{activeTab === 'domains'
|
||||
? 'Добавляйте, редактируйте и управляйте списками доменов'
|
||||
: 'Добавляйте, редактируйте и управляйте списками AS'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="container-xl">
|
||||
{activeTab === 'domains' && (
|
||||
<DataManager
|
||||
entityName="домен"
|
||||
entityKey="domains"
|
||||
placeholder="example.com"
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'asns' && (
|
||||
<DataManager
|
||||
entityName="AS"
|
||||
entityKey="asns"
|
||||
placeholder="AS12345"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="footer footer-transparent d-print-none">
|
||||
<div className="container-xl">
|
||||
<div className="row text-center align-items-center flex-row-reverse">
|
||||
<div className="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||
<ul className="list-inline list-inline-dots mb-0">
|
||||
<li className="list-inline-item">
|
||||
Copyright © 2024
|
||||
<a href="." className="link-secondary">S3 Lists Manager</a>.
|
||||
All rights reserved.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+383
-110
@@ -1,6 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Table, Button, Form, Row, Col, Card, Alert } from 'react-bootstrap';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconArrowsExchange,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconAlertTriangle
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
@@ -14,18 +28,25 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [bulkFrom, setBulkFrom] = useState('SharkFIN');
|
||||
const [bulkTo, setBulkTo] = useState('SharkAM');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/${entityKey}`);
|
||||
setItems(response.data);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${entityKey}:`, error);
|
||||
setError(`Не удалось загрузить ${entityName}. Проверьте, запущен ли бэкенд.`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,15 +81,30 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
setItems(items.filter(i => i.domain !== domainNameToDelete));
|
||||
};
|
||||
|
||||
const confirmDelete = (item) => {
|
||||
setItemToDelete(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const executeDelete = () => {
|
||||
if (itemToDelete) {
|
||||
handleDeleteItem(itemToDelete.domain);
|
||||
setShowDeleteModal(false);
|
||||
setItemToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// The backend expects a payload with a 'domains' key for both endpoints.
|
||||
await axios.post(`${API_URL}/${entityKey}`, { domains: items });
|
||||
setSuccess('Изменения успешно сохранены!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
setError('Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,130 +129,367 @@ function DataManager({ entityName, entityKey, placeholder }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Alert variant="danger" onClose={() => setError('')} dismissible>{error}</Alert>}
|
||||
{success && <Alert variant="success" onClose={() => setSuccess('')} dismissible>{success}</Alert>}
|
||||
{error && (
|
||||
<div className="alert alert-danger alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconX className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
<a className="btn-close" onClick={() => setError('')}></a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconCheck className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
<a className="btn-close" onClick={() => setSuccess('')}></a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Col lg={4}>
|
||||
<Card className="mb-4">
|
||||
<Card.Header as="h5">Добавить новый {entityName}</Card.Header>
|
||||
<Card.Body>
|
||||
<Form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Имя {entityName}</Form.Label>
|
||||
<Form.Control
|
||||
<div className="row">
|
||||
<div className="col-lg-4">
|
||||
{/* Add New Item Card */}
|
||||
<div className="card card-md">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить новый {entityName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Имя {entityName}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={placeholder}
|
||||
value={newItem.domain}
|
||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Шлюз</Form.Label>
|
||||
<Form.Control
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Шлюз</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={newItem.type}
|
||||
onChange={(e) => setNewItem({ ...newItem, type: e.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button variant="primary" type="submit" className="w-100">Добавить</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="mb-4">
|
||||
<Card.Header as="h5">Массовое обновление</Card.Header>
|
||||
<Card.Body>
|
||||
<Form.Group className="mb-2">
|
||||
<Form.Label>Заменить с</Form.Label>
|
||||
<Form.Control type="text" value={bulkFrom} onChange={e => setBulkFrom(e.target.value)} />
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Заменить на</Form.Label>
|
||||
<Form.Control type="text" value={bulkTo} onChange={e => setBulkTo(e.target.value)} />
|
||||
</Form.Group>
|
||||
<Button variant="warning" onClick={handleBulkUpdate} className="w-100">Выполнить замену</Button>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col lg={8}>
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0">Список: {entityName}</h5>
|
||||
<Button variant="success" onClick={handleSaveChanges} disabled={items.length === 0}>
|
||||
Сохранить все изменения в S3
|
||||
</Button>
|
||||
{/* Bulk Update Card */}
|
||||
<div className="card card-md mt-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconArrowsExchange className="icon me-2" />
|
||||
Массовое обновление
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Заменить с</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={bulkFrom}
|
||||
onChange={e => setBulkFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={`Поиск по ${entityName}...`}
|
||||
className="mb-3"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<Table striped bordered hover responsive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{entityName}</th>
|
||||
<th>Шлюз</th>
|
||||
<th className="text-center">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={item.domain}>
|
||||
<td>{index + 1}</td>
|
||||
<td>{item.domain}</td>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
item.type
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{editingDomain === item.domain ? (
|
||||
<>
|
||||
<Button variant="success" size="sm" className="me-2" onClick={() => handleSaveEdit(item.domain)}>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCancelEdit}>
|
||||
Отмена
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="info" size="sm" className="me-2" onClick={() => handleEdit(item)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeleteItem(item.domain)}>
|
||||
Удалить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Заменить на</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={bulkTo}
|
||||
onChange={e => setBulkTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-warning w-100"
|
||||
onClick={handleBulkUpdate}
|
||||
>
|
||||
<IconArrowsExchange className="icon me-2" />
|
||||
Выполнить замену
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<div className="card card-md mt-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconDatabase className="icon me-2" />
|
||||
Статистика
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row text-center">
|
||||
<div className="col">
|
||||
<div className="text-muted mb-1">Всего</div>
|
||||
<div className="h3 mb-0">{items.length}</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="text-muted mb-1">Найдено</div>
|
||||
<div className="h3 mb-0">{filteredItems.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-8">
|
||||
{/* Main Data Card */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<h3 className="card-title">
|
||||
<IconDatabase className="icon me-2" />
|
||||
Список: {entityName}
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-outline-secondary me-2"
|
||||
onClick={fetchItems}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh className="icon me-2" />
|
||||
Обновить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={items.length === 0 || loading}
|
||||
>
|
||||
<IconUpload className="icon me-2" />
|
||||
Сохранить в S3
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{/* Search */}
|
||||
<div className="mb-3">
|
||||
<div className="input-icon">
|
||||
<IconSearch className="icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={`Поиск по ${entityName}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{entityName}</th>
|
||||
<th>Шлюз</th>
|
||||
<th className="w-1"></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={item.domain}>
|
||||
<td className="text-muted">{index + 1}</td>
|
||||
<td>
|
||||
<div className="font-weight-medium">{item.domain}</div>
|
||||
</td>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="badge bg-blue-lt">{item.type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingDomain === item.domain ? (
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleSaveEdit(item.domain)}
|
||||
>
|
||||
<IconCheck className="icon" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
<IconX className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<IconEdit className="icon" />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => confirmDelete(item)}
|
||||
>
|
||||
<IconTrash className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Empty States */}
|
||||
{items.length > 0 && filteredItems.length === 0 && (
|
||||
<p className="text-center text-muted">По вашему запросу ничего не найдено.</p>
|
||||
<div className="empty">
|
||||
<div className="empty-icon">
|
||||
<IconSearch className="icon" />
|
||||
</div>
|
||||
<p className="empty-title">Ничего не найдено</p>
|
||||
<p className="empty-subtitle text-muted">
|
||||
По вашему запросу ничего не найдено.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 && <p className="text-center text-muted">Список пуст. Добавьте новый элемент.</p>}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="empty">
|
||||
<div className="empty-icon">
|
||||
<IconDatabase className="icon" />
|
||||
</div>
|
||||
<p className="empty-title">Список пуст</p>
|
||||
<p className="empty-subtitle text-muted">
|
||||
Добавьте новый элемент, чтобы начать работу.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
<span className="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="modal-backdrop" style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1050,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div className="modal-dialog" style={{
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
margin: '1.75rem auto'
|
||||
}}>
|
||||
<div className="modal-content" style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 0.5rem 1rem rgba(0, 0, 0, 0.15)'
|
||||
}}>
|
||||
<div className="modal-header" style={{
|
||||
padding: '1rem 1.5rem',
|
||||
borderBottom: '1px solid var(--tblr-border-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<IconAlertTriangle className="icon me-2" style={{ color: 'var(--tblr-danger)' }} />
|
||||
<h5 className="modal-title" style={{ margin: 0 }}>Подтверждение удаления</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
marginLeft: 'auto'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||
<p>Вы уверены, что хотите удалить <strong>{itemToDelete?.domain}</strong>?</p>
|
||||
<p className="text-muted mb-0">Это действие нельзя отменить.</p>
|
||||
</div>
|
||||
<div className="modal-footer" style={{
|
||||
padding: '1rem 1.5rem',
|
||||
borderTop: '1px solid var(--tblr-border-color)',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={executeDelete}
|
||||
>
|
||||
<IconTrash className="icon me-2" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user