feat: Add Tabler UI support and enhance frontend with new features
Publish Tabler Docker image / build-and-push-tabler (push) Failing after 6m47s

This commit is contained in:
2025-07-08 22:50:28 +07:00
parent 50aab806cc
commit 0c1c156bb7
11 changed files with 1983 additions and 154 deletions
@@ -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 }}
+7 -1
View File
@@ -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 }}
+208
View File
@@ -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.
+70 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+27
View File
@@ -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,
+1
View File
@@ -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",
+929
View File
@@ -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
View File
@@ -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 &copy; 2024
<a href="." className="link-secondary">S3 Lists Manager</a>.
All rights reserved.
</li>
</ul>
</div>
</div>
</div>
</footer>
</div>
</div>
);
}
+383 -110
View File
@@ -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>
)}
</>
);
}