0.5.8-panel-upgrade-community-crud-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s
All checks were successful
Deploy on push / deploy (push) Successful in 6s
This commit is contained in:
parent
9de86c0fae
commit
952b294345
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -164,3 +164,8 @@ views.json
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
panel/graphql/generated/
|
||||||
|
panel/types.gen.ts
|
||||||
|
|
||||||
|
.cursorrules
|
||||||
|
.cursor/
|
||||||
|
|
156
CHANGELOG.md
156
CHANGELOG.md
|
@ -1,5 +1,159 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.8] - 2025-06-30
|
||||||
|
|
||||||
|
### Улучшения интерфейса публикаций
|
||||||
|
|
||||||
|
- **НОВОЕ**: Статусы публикаций иконками:
|
||||||
|
- **Опубликовано**: ✅ (зелёный бэдж) - быстрая визуальная идентификация опубликованных статей
|
||||||
|
- **Черновик**: 📝 (жёлтый бэдж) - чёткое обозначение незавершённых публикаций
|
||||||
|
- **Удалено**: 🗑️ (красный бэдж) - явное указание на удалённые материалы
|
||||||
|
- **Компактный дизайн**: Статус-бэджи 32×32px с центрированными иконками для экономии места
|
||||||
|
- **Tooltip поддержка**: При наведении показывается текстовое описание статуса для полной ясности
|
||||||
|
|
||||||
|
- **УЛУЧШЕНО**: Выравнивание элементов управления:
|
||||||
|
- **Логичная группировка**: Поиск и элементы управления размещены в одной строке слева направо
|
||||||
|
- **Убран разброс**: Элементы больше не разбросаны по разным концам экрана (`justify-content: space-between`)
|
||||||
|
- **Удалён фильтр статуса**: Упрощён интерфейс за счёт удаления избыточного селектора фильтрации
|
||||||
|
- **Flex gap**: Равномерные отступы 1.5rem между элементами управления
|
||||||
|
- **Responsive дизайн**: Элементы корректно переносятся на мобильных устройствах (`flex-wrap`)
|
||||||
|
|
||||||
|
- **Архитектурные улучшения**:
|
||||||
|
- **Функция getShoutStatusTitle()**: Отдельная функция для получения текстового описания статуса
|
||||||
|
- **Обновлённые CSS классы**: Модернизированные стили для status-badge с flexbox центрированием
|
||||||
|
- **Лучшая семантика**: Title атрибуты для accessibility и пользовательского опыта
|
||||||
|
|
||||||
|
### Сортировка топиков и управление сообществами
|
||||||
|
|
||||||
|
- **НОВОЕ**: Сортировка топиков в админ-панели:
|
||||||
|
- **Выпадающий селектор**: Выбор между сортировкой по ID и названию
|
||||||
|
- **Направление сортировки**: По возрастанию/убыванию с интуитивными стрелочками ↑↓
|
||||||
|
- **Умная русская сортировка**: Использование `localeCompare('ru')` для корректной сортировки русских названий
|
||||||
|
- **Рекурсивная сортировка**: Дочерние топики также сортируются по выбранному критерию
|
||||||
|
- **Реактивность**: Автоматическое пересортирование при изменении параметров
|
||||||
|
- **Сохранение иерархии**: Древовидная структура сохраняется при любом типе сортировки
|
||||||
|
|
||||||
|
- **НОВОЕ**: Полноценное управление сообществами:
|
||||||
|
- **Новая вкладка "Сообщества"**: Отдельная секция в админ-панели для управления сообществами
|
||||||
|
- **Подробная таблица**: ID, название, slug, описание, создатель, статистика (публикации/подписчики/авторы), дата создания
|
||||||
|
- **Клик для редактирования**: Нажатие на строку открывает модалку редактирования сообщества
|
||||||
|
- **Удаление с подтверждением**: Тонкая кнопка "×" для удаления с двойным подтверждением
|
||||||
|
- **Полная CRUD функциональность**: Создание, редактирование, удаление сообществ
|
||||||
|
- **Исправлена проблема с загрузкой**: Добавлен relationship для `created_by` в ORM модели Community
|
||||||
|
- **Резолвер поля created_by**: Корректное получение информации о создателе сообщества
|
||||||
|
|
||||||
|
### Улучшенное управление пользователями
|
||||||
|
|
||||||
|
- **КАРДИНАЛЬНО НОВАЯ модалка редактирования пользователя**:
|
||||||
|
- **Красивый современный дизайн**: Карточки для ролей, секционное разделение, современная типографика
|
||||||
|
- **Полное редактирование профиля**: Email, имя, slug, роли (не только роли как раньше)
|
||||||
|
- **Умная валидация**: Проверка email, обязательных полей, уникальности slug
|
||||||
|
- **Информационная панель**: Отображение ID, даты регистрации, последней активности
|
||||||
|
- **Интерактивные карточки ролей**: Описание каждой роли с иконками состояния
|
||||||
|
- **Расширенная GraphQL схема**: `AdminUserUpdateInput` теперь поддерживает email, name, slug
|
||||||
|
- **Улучшенный резолвер**: `adminUpdateUser` обрабатывает профильные поля с проверкой уникальности
|
||||||
|
- **Реальная валидация**: Проверка email и slug на уникальность в базе данных
|
||||||
|
- **Детальное логирование**: Подробные сообщения об изменениях в профиле и ролях
|
||||||
|
|
||||||
|
- **ТЕХНИЧЕСКАЯ АРХИТЕКТУРА**:
|
||||||
|
- **Переименование компонента**: `RolesModal` → `UserEditModal` для отражения расширенного функционала
|
||||||
|
- **Новые CSS стили**: Добавлены стили для форм, карточек ролей, валидации в `Form.module.css`
|
||||||
|
- **Обновленный API интерфейс**: `onSave` теперь принимает полный объект пользователя вместо только ролей
|
||||||
|
- **Реактивная форма**: Автоочистка ошибок при изменении полей, сброс состояния при открытии
|
||||||
|
|
||||||
|
### Полноценное редактирование топиков в админ-панели
|
||||||
|
|
||||||
|
- **НОВОЕ**: Редактирование всех полей топиков:
|
||||||
|
- **Колонка ID**: Отображение идентификаторов топиков в таблице для точной идентификации
|
||||||
|
- **Редактирование названия**: Изменение `title` прямо в модальном окне
|
||||||
|
- **Простой HTML редактор**: Обычный `contenteditable` div вместо сложного редактора кода
|
||||||
|
- **Управление сообществом**: Изменение `community` ID с валидацией
|
||||||
|
- **Управление иерархией**: Редактирование `parent_ids` (список родительских топиков через запятую)
|
||||||
|
- **Картинки**: Редактирование URL картинки (`pic`)
|
||||||
|
|
||||||
|
- **Улучшения UI/UX**:
|
||||||
|
- **Клик по строке для редактирования**: Убрана кнопка "Редактировать", модалка открывается кликом на любом месте строки
|
||||||
|
- **Ненавязчивый крестик удаления**: Простая кнопка "×" серого цвета, которая становится красной при наведении
|
||||||
|
- **Колонка "Родители"**: Отображение списка parent_ids в основной таблице
|
||||||
|
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом и placeholder
|
||||||
|
- **Подтверждение удаления**: Модальное окно при клике на крестик
|
||||||
|
|
||||||
|
- **Архитектурные улучшения**:
|
||||||
|
- **TopicInput расширен**: Добавлены поля `community` и `parent_ids` в GraphQL схему
|
||||||
|
- **Новые мутации**: `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION` в mutations.ts
|
||||||
|
- **TopicEditModal**: Переиспользуемый компонент с простым интерфейсом
|
||||||
|
- **Парсинг parent_ids**: Автоматическое преобразование строки "1, 5, 12" в массив чисел
|
||||||
|
- **Синхронизация данных**: createEffect для синхронизации формы с выбранным топиком
|
||||||
|
|
||||||
|
- **Технические детали**:
|
||||||
|
- **Кликабельные строки**: Hover эффект и cursor pointer для лучшего UX
|
||||||
|
- **Prevent event bubbling**: Правильная обработка клика на крестике без открытия модалки
|
||||||
|
- **CSS стили**: Стили для hover эффектов крестика и placeholder в contenteditable
|
||||||
|
- **Валидация**: Обязательное поле `slug`, проверка числовых полей
|
||||||
|
- **Обработка ошибок**: Корректное отображение ошибок GraphQL
|
||||||
|
- **Автообновление**: Перезагрузка списка топиков после успешного сохранения
|
||||||
|
|
||||||
|
### Рефакторинг админ-панели
|
||||||
|
|
||||||
|
- **ИСПРАВЛЕНО**: Переключение табов в админ-панели:
|
||||||
|
- **Проблема**: Роутинг не работал корректно - табы не переключались при клике
|
||||||
|
- **Решение**: Заменен `useLocation` на `useParams` для корректного получения активной вкладки
|
||||||
|
- **Улучшения**: Исправлена логика навигации с `replace: true` для редиректа на `/admin/authors`
|
||||||
|
- **Результат**: Теперь переключение между табами работает плавно и корректно
|
||||||
|
|
||||||
|
- **НОВОЕ**: Управление топиками в админ-панели:
|
||||||
|
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─`
|
||||||
|
- **Удаление в один клик**: Кнопка удаления с модальным окном подтверждения
|
||||||
|
- **Информативная таблица**: Название, slug, описание, сообщество, действия
|
||||||
|
- **Предупреждения**: Информация о том что дочерние топики также будут удалены
|
||||||
|
- **Автообновление**: Список перезагружается после успешного удаления
|
||||||
|
|
||||||
|
### Codegen рефакторинг
|
||||||
|
|
||||||
|
- **GraphQL Codegen**: Настроена автоматическая генерация TypeScript типов:
|
||||||
|
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
|
||||||
|
- **Автоматические типы**: Генерация из GraphQL схемы в `panel/graphql/generated/`
|
||||||
|
- **Структура**: Разделение на queries, mutations и index файлы
|
||||||
|
- **TypeScript интеграция**: Полная типизация для админ-панели
|
||||||
|
|
||||||
|
- **Архитектурные улучшения**:
|
||||||
|
- **Модульная структура**: Разделение GraphQL операций по назначению
|
||||||
|
- **Type safety**: Строгая типизация для всех GraphQL операций
|
||||||
|
- **Developer Experience**: Автокомплит и проверка типов в IDE
|
||||||
|
|
||||||
|
### Улучшения системы кеширования
|
||||||
|
|
||||||
|
- **НОВОЕ**: Функция `invalidate_topic_followers_cache()` в модуле cache:
|
||||||
|
- **Централизованная логика**: Все операции по инвалидации кешей подписчиков в одном месте
|
||||||
|
- **Комплексная обработка**: Инвалидация кешей как самого топика, так и всех его подписчиков
|
||||||
|
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
|
||||||
|
- **Подробное логирование**: Отслеживание всех операций инвалидации для отладки
|
||||||
|
|
||||||
|
- **Исправлена логика удаления топиков**:
|
||||||
|
- **Проблема**: При удалении топика не обновлялись счетчики подписок у всех подписчиков
|
||||||
|
- **Решение**: Добавлена инвалидация персональных кешей для каждого подписчика:
|
||||||
|
- `author:follows-topics:{follower_id}` - список подписок на топики
|
||||||
|
- `author:followers:{follower_id}` - счетчики подписчиков
|
||||||
|
- `author:stat:{follower_id}` - общая статистика автора
|
||||||
|
- **Результат**: Система поддерживает консистентность кешей при удалении топиков
|
||||||
|
|
||||||
|
- **Архитектурные улучшения**:
|
||||||
|
- **Разделение ответственности**: Cache модуль отвечает за кеширование, резолверы за бизнес-логику
|
||||||
|
- **Переиспользуемость**: Функцию можно использовать в других операциях с топиками
|
||||||
|
- **Тестируемость**: Логику кеширования легко мокать и тестировать отдельно
|
||||||
|
|
||||||
|
### GraphQL Schema
|
||||||
|
|
||||||
|
- **Новые операции**:
|
||||||
|
- `delete_topic_by_id(id: Int!)` - удаление топика по ID для админ-панели
|
||||||
|
- Обновленный `get_topics_all` для корректной типизации
|
||||||
|
|
||||||
|
### Исправления резолверов
|
||||||
|
|
||||||
|
- **Использование существующей схемы**: Приведение кода в соответствие с truth source схемой GraphQL
|
||||||
|
- **Упрощение**: Убраны дублирующиеся резолверы, используются существующие `get_topics_all`
|
||||||
|
- **Чистота кода**: Удалена дублированная логика инвалидации кешей
|
||||||
|
|
||||||
## [0.5.7] - 2025-06-28
|
## [0.5.7] - 2025-06-28
|
||||||
|
|
||||||
### Новая функциональность админ-панели
|
### Новая функциональность админ-панели
|
||||||
|
@ -466,7 +620,7 @@
|
||||||
- Modified `load_reactions_by` to include deleted reactions when `include_deleted=true` for proper comment tree building
|
- Modified `load_reactions_by` to include deleted reactions when `include_deleted=true` for proper comment tree building
|
||||||
- Fixed featured/unfeatured logic in reaction processing:
|
- Fixed featured/unfeatured logic in reaction processing:
|
||||||
- Dislike reactions now properly take precedence over likes
|
- Dislike reactions now properly take precedence over likes
|
||||||
- Featured status now requires more than 4 likes from users with featured articles
|
- Featured status now requires more than 4 likes from authors with featured articles
|
||||||
- Removed unnecessary filters for deleted reactions since rating reactions are physically deleted
|
- Removed unnecessary filters for deleted reactions since rating reactions are physically deleted
|
||||||
- Author's featured status now based on having non-deleted articles with featured_at
|
- Author's featured status now based on having non-deleted articles with featured_at
|
||||||
|
|
||||||
|
|
|
@ -380,49 +380,96 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
|
||||||
|
|
||||||
def login_accepted(func: Callable) -> Callable:
|
def login_accepted(func: Callable) -> Callable:
|
||||||
"""
|
"""
|
||||||
Декоратор для резолверов, которые могут работать как с авторизованными,
|
Декоратор для проверки аутентификации пользователя.
|
||||||
так и с неавторизованными пользователями.
|
|
||||||
|
|
||||||
Добавляет информацию о пользователе в контекст, если пользователь авторизован.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
func: Декорируемая функция
|
func: функция-резолвер для декорирования
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callable: обернутая функция
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||||
try:
|
|
||||||
# Пробуем проверить авторизацию, но не выбрасываем исключение, если пользователь не авторизован
|
|
||||||
try:
|
try:
|
||||||
await validate_graphql_context(info)
|
await validate_graphql_context(info)
|
||||||
except GraphQLError:
|
|
||||||
# Игнорируем ошибку авторизации
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Получаем объект авторизации
|
|
||||||
auth = None
|
|
||||||
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
|
|
||||||
auth = info.context["request"].scope.get("auth")
|
|
||||||
|
|
||||||
if auth and getattr(auth, "logged_in", False):
|
|
||||||
# Если пользователь авторизован, добавляем информацию о нем в контекст
|
|
||||||
with local_session() as session:
|
|
||||||
try:
|
|
||||||
author = session.query(Author).filter(Author.id == auth.author_id).one()
|
|
||||||
info.context["author"] = author.dict()
|
|
||||||
logger.debug(f"[login_accepted] Пользователь авторизован: {author.id}")
|
|
||||||
except exc.NoResultFound:
|
|
||||||
logger.warning(f"[login_accepted] Пользователь с ID {auth.author_id} не найден в базе данных")
|
|
||||||
info.context["author"] = None
|
|
||||||
else:
|
|
||||||
# Если пользователь не авторизован, устанавливаем пустые значения
|
|
||||||
info.context["author"] = None
|
|
||||||
logger.debug("[login_accepted] Пользователь не авторизован")
|
|
||||||
|
|
||||||
return await func(parent, info, *args, **kwargs)
|
return await func(parent, info, *args, **kwargs)
|
||||||
except Exception as e:
|
except GraphQLError:
|
||||||
if not isinstance(e, GraphQLError):
|
# Пробрасываем ошибки авторизации далее
|
||||||
logger.error(f"[login_accepted] Ошибка: {e}")
|
|
||||||
raise
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[decorators] Unexpected error in login_accepted: {e}")
|
||||||
|
msg = "Internal server error"
|
||||||
|
raise GraphQLError(msg) from e
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
def editor_or_admin_required(func: Callable) -> Callable:
|
||||||
|
"""
|
||||||
|
Декоратор для проверки, что пользователь имеет роль 'editor' или 'admin'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: функция-резолвер для декорирования
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callable: обернутая функция
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
try:
|
||||||
|
# Сначала проверяем авторизацию
|
||||||
|
await validate_graphql_context(info)
|
||||||
|
|
||||||
|
# Получаем информацию о пользователе
|
||||||
|
request = info.context.get("request")
|
||||||
|
author_id = None
|
||||||
|
|
||||||
|
# Пробуем получить author_id из разных источников
|
||||||
|
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
|
||||||
|
author_id = request.auth.author_id
|
||||||
|
elif hasattr(request, "scope") and "auth" in request.scope:
|
||||||
|
auth_info = request.scope.get("auth", {})
|
||||||
|
if isinstance(auth_info, dict):
|
||||||
|
author_id = auth_info.get("author_id")
|
||||||
|
elif hasattr(auth_info, "author_id"):
|
||||||
|
author_id = auth_info.author_id
|
||||||
|
|
||||||
|
if not author_id:
|
||||||
|
logger.warning("[decorators] Не удалось получить author_id для проверки ролей")
|
||||||
|
raise GraphQLError("Ошибка авторизации: не удалось определить пользователя")
|
||||||
|
|
||||||
|
# Проверяем роли пользователя
|
||||||
|
with local_session() as session:
|
||||||
|
author = session.query(Author).filter(Author.id == author_id).first()
|
||||||
|
if not author:
|
||||||
|
logger.warning(f"[decorators] Автор с ID {author_id} не найден")
|
||||||
|
raise GraphQLError("Пользователь не найден")
|
||||||
|
|
||||||
|
# Проверяем email админа
|
||||||
|
if author.email in ADMIN_EMAILS:
|
||||||
|
logger.debug(f"[decorators] Пользователь {author.email} является админом по email")
|
||||||
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
# Получаем список ролей пользователя
|
||||||
|
user_roles = [role.id for role in author.roles] if author.roles else []
|
||||||
|
logger.debug(f"[decorators] Роли пользователя {author_id}: {user_roles}")
|
||||||
|
|
||||||
|
# Проверяем наличие роли admin или editor
|
||||||
|
if "admin" in user_roles or "editor" in user_roles:
|
||||||
|
logger.debug(f"[decorators] Пользователь {author_id} имеет разрешение (роли: {user_roles})")
|
||||||
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
# Если нет нужных ролей
|
||||||
|
logger.warning(f"[decorators] Пользователю {author_id} отказано в доступе. Роли: {user_roles}")
|
||||||
|
raise GraphQLError("Доступ запрещен. Требуется роль редактора или администратора.")
|
||||||
|
|
||||||
|
except GraphQLError:
|
||||||
|
# Пробрасываем ошибки авторизации далее
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[decorators] Неожиданная ошибка в editor_or_admin_required: {e}")
|
||||||
|
raise GraphQLError("Внутренняя ошибка сервера") from e
|
||||||
|
|
||||||
return wrap
|
return wrap
|
||||||
|
|
|
@ -200,14 +200,14 @@ async def _fetch_facebook_profile(client: Any, token: Any) -> dict:
|
||||||
|
|
||||||
async def _fetch_x_profile(client: Any, token: Any) -> dict:
|
async def _fetch_x_profile(client: Any, token: Any) -> dict:
|
||||||
"""Получает профиль из X (Twitter) API"""
|
"""Получает профиль из X (Twitter) API"""
|
||||||
profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token)
|
profile = await client.get("authors/me?user.fields=id,name,username,profile_image_url", token=token)
|
||||||
profile_data = profile.json()
|
profile_data = profile.json()
|
||||||
return PROVIDER_HANDLERS["x"](token, profile_data)
|
return PROVIDER_HANDLERS["x"](token, profile_data)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_vk_profile(client: Any, token: Any) -> dict:
|
async def _fetch_vk_profile(client: Any, token: Any) -> dict:
|
||||||
"""Получает профиль из VK API"""
|
"""Получает профиль из VK API"""
|
||||||
profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.131", token=token)
|
profile = await client.get("authors.get?fields=photo_400_orig,contacts&v=5.131", token=token)
|
||||||
profile_data = profile.json()
|
profile_data = profile.json()
|
||||||
if profile_data.get("response"):
|
if profile_data.get("response"):
|
||||||
user_data = profile_data["response"][0]
|
user_data = profile_data["response"][0]
|
||||||
|
|
60
biome.json
60
biome.json
|
@ -1,8 +1,19 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
|
"includes": [
|
||||||
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
|
"**/*.tsx",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.js",
|
||||||
|
"**/*.json",
|
||||||
|
"!dist",
|
||||||
|
"!node_modules",
|
||||||
|
"!**/.husky",
|
||||||
|
"!**/docs",
|
||||||
|
"!**/gen",
|
||||||
|
"!**/*.gen.ts",
|
||||||
|
"!**/*.d.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
@ -10,16 +21,13 @@
|
||||||
"useIgnoreFile": true,
|
"useIgnoreFile": true,
|
||||||
"clientKind": "git"
|
"clientKind": "git"
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"enabled": true,
|
|
||||||
"ignore": ["./gen"]
|
|
||||||
},
|
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"lineWidth": 108,
|
"lineWidth": 108,
|
||||||
"ignore": ["./src/graphql/schema", "./gen"]
|
"includes": ["**", "!src/graphql/schema", "!gen", "!panel/graphql/generated"]
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
@ -33,11 +41,11 @@
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"],
|
"includes": ["**", "!**/*.scss", "!**/*.md", "!**/.DS_Store", "!**/*.svg", "!**/*.d.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"all": true,
|
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off",
|
"noForEach": "off",
|
||||||
|
"noUselessFragments": "off",
|
||||||
"useOptionalChain": "warn",
|
"useOptionalChain": "warn",
|
||||||
"useLiteralKeys": "off",
|
"useLiteralKeys": "off",
|
||||||
"noExcessiveCognitiveComplexity": "off",
|
"noExcessiveCognitiveComplexity": "off",
|
||||||
|
@ -46,10 +54,7 @@
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useHookAtTopLevel": "off",
|
"useHookAtTopLevel": "off",
|
||||||
"useImportExtensions": "off",
|
"useImportExtensions": "off",
|
||||||
"noUndeclaredDependencies": "off",
|
"noUndeclaredDependencies": "off"
|
||||||
"noNodejsModules": {
|
|
||||||
"level": "off"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"useHeadingContent": "off",
|
"useHeadingContent": "off",
|
||||||
|
@ -61,18 +66,16 @@
|
||||||
"useAltText": "off",
|
"useAltText": "off",
|
||||||
"useButtonType": "off",
|
"useButtonType": "off",
|
||||||
"noRedundantAlt": "off",
|
"noRedundantAlt": "off",
|
||||||
|
"noStaticElementInteractions": "off",
|
||||||
"noSvgWithoutTitle": "off",
|
"noSvgWithoutTitle": "off",
|
||||||
"noLabelWithoutControl": "off"
|
"noLabelWithoutControl": "off"
|
||||||
},
|
},
|
||||||
"nursery": {
|
|
||||||
"useImportRestrictions": "off"
|
|
||||||
},
|
|
||||||
"performance": {
|
"performance": {
|
||||||
"noBarrelFile": "off"
|
"noBarrelFile": "off",
|
||||||
|
"noNamespaceImport": "warn"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "off",
|
"noNonNullAssertion": "off",
|
||||||
"noNamespaceImport": "warn",
|
|
||||||
"noUselessElse": "off",
|
"noUselessElse": "off",
|
||||||
"useBlockStatements": "off",
|
"useBlockStatements": "off",
|
||||||
"noImplicitBoolean": "off",
|
"noImplicitBoolean": "off",
|
||||||
|
@ -81,12 +84,25 @@
|
||||||
"noDefaultExport": "off",
|
"noDefaultExport": "off",
|
||||||
"useFilenamingConvention": "off",
|
"useFilenamingConvention": "off",
|
||||||
"useExplicitLengthCheck": "off",
|
"useExplicitLengthCheck": "off",
|
||||||
"useNodejsImportProtocol": "off"
|
"noParameterAssign": "error",
|
||||||
|
"useAsConstAssertion": "error",
|
||||||
|
"useDefaultParameterLast": "error",
|
||||||
|
"useEnumInitializers": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useSingleVarDeclarator": "error",
|
||||||
|
"noUnusedTemplateLiteral": "error",
|
||||||
|
"useNumberNamespace": "error",
|
||||||
|
"noInferrableTypes": "error"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noConsole": "off",
|
"noConsole": "off",
|
||||||
"noConsoleLog": "off",
|
"noAssignInExpressions": "off",
|
||||||
"noAssignInExpressions": "off"
|
"useAwait": "off",
|
||||||
|
"noEmptyBlockStatements": "off"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"noFloatingPromises": "warn",
|
||||||
|
"noImportCycles": "warn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
98
cache/cache.py
vendored
98
cache/cache.py
vendored
|
@ -462,11 +462,8 @@ async def cache_related_entities(shout: Shout) -> None:
|
||||||
"""
|
"""
|
||||||
Кэширует все связанные с публикацией сущности (авторов и темы)
|
Кэширует все связанные с публикацией сущности (авторов и темы)
|
||||||
"""
|
"""
|
||||||
tasks = []
|
tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
|
||||||
for author in shout.authors:
|
tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
|
||||||
tasks.append(cache_by_id(Author, author.id, cache_author))
|
|
||||||
for topic in shout.topics:
|
|
||||||
tasks.append(cache_by_id(Topic, topic.id, cache_topic))
|
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@ -846,22 +843,85 @@ async def invalidate_author_cache(author_id: Union[int, str]) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def clear_all_cache() -> None:
|
async def clear_all_cache() -> None:
|
||||||
"""Очищает весь кеш (использовать осторожно)"""
|
"""
|
||||||
|
Очищает весь кэш Redis (используйте с осторожностью!)
|
||||||
|
|
||||||
|
Warning:
|
||||||
|
Эта функция удаляет ВСЕ данные из Redis!
|
||||||
|
Используйте только в тестовой среде или при критической необходимости.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all cache keys
|
await redis.execute("FLUSHDB")
|
||||||
topic_keys = await redis.keys("topic:*")
|
logger.info("Весь кэш очищен")
|
||||||
author_keys = await redis.keys("author:*")
|
except Exception as e:
|
||||||
search_keys = await redis.keys("search:*")
|
logger.error(f"Ошибка при очистке кэша: {e}")
|
||||||
follows_keys = await redis.keys("follows:*")
|
|
||||||
|
|
||||||
all_keys = topic_keys + author_keys + search_keys + follows_keys
|
|
||||||
|
|
||||||
if all_keys:
|
async def invalidate_topic_followers_cache(topic_id: int) -> None:
|
||||||
for key in all_keys:
|
"""
|
||||||
await redis.delete(key)
|
Инвалидирует кеши подписчиков при удалении топика.
|
||||||
logger.info(f"Cleared {len(all_keys)} cache entries")
|
|
||||||
else:
|
Эта функция:
|
||||||
logger.info("No cache entries to clear")
|
1. Получает список всех подписчиков топика
|
||||||
|
2. Инвалидирует персональные кеши подписок для каждого подписчика
|
||||||
|
3. Инвалидирует кеши самого топика
|
||||||
|
4. Логирует процесс для отладки
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic_id: ID топика для которого нужно инвалидировать кеши подписчиков
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"Инвалидация кешей подписчиков для топика {topic_id}")
|
||||||
|
|
||||||
|
# Получаем список всех подписчиков топика из БД
|
||||||
|
with local_session() as session:
|
||||||
|
followers_query = session.query(TopicFollower.follower).filter(TopicFollower.topic == topic_id)
|
||||||
|
follower_ids = [row[0] for row in followers_query.all()]
|
||||||
|
|
||||||
|
logger.debug(f"Найдено {len(follower_ids)} подписчиков топика {topic_id}")
|
||||||
|
|
||||||
|
# Инвалидируем кеши подписок для всех подписчиков
|
||||||
|
for follower_id in follower_ids:
|
||||||
|
cache_keys_to_delete = [
|
||||||
|
f"author:follows-topics:{follower_id}", # Список топиков на которые подписан автор
|
||||||
|
f"author:followers:{follower_id}", # Счетчик подписчиков автора
|
||||||
|
f"author:stat:{follower_id}", # Общая статистика автора
|
||||||
|
f"author:id:{follower_id}", # Кешированные данные автора
|
||||||
|
]
|
||||||
|
|
||||||
|
for cache_key in cache_keys_to_delete:
|
||||||
|
try:
|
||||||
|
await redis.execute("DEL", cache_key)
|
||||||
|
logger.debug(f"Удален кеш: {cache_key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении кеша {cache_key}: {e}")
|
||||||
|
|
||||||
|
# Инвалидируем кеши самого топика
|
||||||
|
topic_cache_keys = [
|
||||||
|
f"topic:followers:{topic_id}", # Список подписчиков топика
|
||||||
|
f"topic:id:{topic_id}", # Данные топика по ID
|
||||||
|
f"topic:authors:{topic_id}", # Авторы топика
|
||||||
|
f"topic_shouts_{topic_id}", # Публикации топика (legacy format)
|
||||||
|
]
|
||||||
|
|
||||||
|
for cache_key in topic_cache_keys:
|
||||||
|
try:
|
||||||
|
await redis.execute("DEL", cache_key)
|
||||||
|
logger.debug(f"Удален кеш топика: {cache_key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении кеша топика {cache_key}: {e}")
|
||||||
|
|
||||||
|
# Также ищем и удаляем коллекционные кеши, содержащие данные об этом топике
|
||||||
|
try:
|
||||||
|
collection_keys = await redis.execute("KEYS", "topics:stats:*")
|
||||||
|
if collection_keys:
|
||||||
|
await redis.execute("DEL", *collection_keys)
|
||||||
|
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении коллекционных кешей: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Успешно инвалидированы кеши для топика {topic_id} и {len(follower_ids)} подписчиков")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to clear cache: {e}")
|
logger.error(f"Ошибка при инвалидации кешей подписчиков топика {topic_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
38
codegen.ts
Normal file
38
codegen.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import type { CodegenConfig } from '@graphql-codegen/cli'
|
||||||
|
|
||||||
|
const config: CodegenConfig = {
|
||||||
|
overwrite: true,
|
||||||
|
schema: [
|
||||||
|
'schema/type.graphql',
|
||||||
|
'schema/enum.graphql',
|
||||||
|
'schema/input.graphql',
|
||||||
|
'schema/mutation.graphql',
|
||||||
|
'schema/query.graphql',
|
||||||
|
'schema/admin.graphql'
|
||||||
|
],
|
||||||
|
documents: ['panel/**/*.{ts,tsx}'],
|
||||||
|
generates: {
|
||||||
|
'./panel/graphql/generated/': {
|
||||||
|
preset: 'client',
|
||||||
|
plugins: [],
|
||||||
|
presetConfig: {
|
||||||
|
gqlTagName: 'gql',
|
||||||
|
fragmentMasking: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'./panel/graphql/generated/schema.ts': {
|
||||||
|
plugins: ['typescript', 'typescript-resolvers'],
|
||||||
|
config: {
|
||||||
|
contextType: '../types#GraphQLContext',
|
||||||
|
enumsAsTypes: true,
|
||||||
|
useIndexSignature: true,
|
||||||
|
scalars: {
|
||||||
|
DateTime: 'string',
|
||||||
|
JSON: '{ [key: string]: any }'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
|
@ -32,6 +32,14 @@ python dev.py
|
||||||
### Администрирование
|
### Администрирование
|
||||||
- **Админ-панель**: Управление пользователями, ролями, переменными среды
|
- **Админ-панель**: Управление пользователями, ролями, переменными среды
|
||||||
- **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные)
|
- **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные)
|
||||||
|
- **Управление топиками**: Упрощенное редактирование топиков с иерархическим отображением
|
||||||
|
- **Клик по строке**: Модалка редактирования открывается при клике на строку таблицы
|
||||||
|
- **Ненавязчивый крестик**: Серая кнопка "×" для удаления, краснеет при hover
|
||||||
|
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом
|
||||||
|
- **Редактируемые поля**: ID (просмотр), название, slug, описание, сообщество, родители
|
||||||
|
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
|
||||||
|
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
|
||||||
|
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
|
||||||
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
|
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
|
||||||
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
|
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,48 @@
|
||||||
|
## Админ-панель
|
||||||
|
|
||||||
|
- **Управление пользователями**: Просмотр, поиск, назначение ролей (user/moderator/admin)
|
||||||
|
- **Управление публикациями**: Таблица со всеми публикациями, фильтрация по статусу, превью контента
|
||||||
|
- **Управление топиками**: Полноценное редактирование топиков в админ-панели
|
||||||
|
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─` для дочерних элементов
|
||||||
|
- **Колонки таблицы**: ID, название, slug, описание, сообщество, родители, действия
|
||||||
|
- **Простой интерфейс редактирования**:
|
||||||
|
- **Клик по строке**: Модалка редактирования открывается при клике на любом месте строки таблицы
|
||||||
|
- **Ненавязчивый крестик**: Кнопка удаления в виде серого "×", краснеет при hover
|
||||||
|
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом вместо сложного редактора
|
||||||
|
- **Редактируемые поля**:
|
||||||
|
- **ID**: Отображается для идентификации (поле только для чтения)
|
||||||
|
- **Название и slug**: Текстовые поля для основной информации
|
||||||
|
- **Описание**: Простой HTML редактор с placeholder
|
||||||
|
- **Картинка**: URL изображения топика
|
||||||
|
- **Сообщество**: ID сообщества с числовой валидацией
|
||||||
|
- **Родители**: Список parent_ids через запятую с автоматическим парсингом
|
||||||
|
- **Безопасное удаление**: Модальное окно подтверждения при клике на крестик
|
||||||
|
- **Корректная инвалидация кешей**: Автоматическое обновление счетчиков подписок у всех подписчиков
|
||||||
|
- **GraphQL интеграция**: Использование мутаций `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION`
|
||||||
|
- **Управление переменными среды**: Настройка конфигурации приложения
|
||||||
|
- **TypeScript интеграция**: Полная типизация с автогенерацией типов из GraphQL схемы
|
||||||
|
- **Responsive дизайн**: Адаптивность для разных размеров экранов
|
||||||
|
|
||||||
|
## Codegen интеграция
|
||||||
|
|
||||||
|
- **Автоматическая генерация типов**: TypeScript типы генерируются из GraphQL схемы
|
||||||
|
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
|
||||||
|
- **Структура проекта**: Разделение на queries, mutations и index файлы в `panel/graphql/generated/`
|
||||||
|
- **Type safety**: Строгая типизация для всех GraphQL операций в админ-панели
|
||||||
|
- **Developer Experience**: Автокомплит и проверка типов в IDE
|
||||||
|
|
||||||
|
## Улучшенная система кеширования топиков
|
||||||
|
|
||||||
|
- **Централизованная функция**: `invalidate_topic_followers_cache()` в модуле cache
|
||||||
|
- **Комплексная инвалидация**: Обработка кешей как самого топика, так и всех его подписчиков
|
||||||
|
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
|
||||||
|
- **Инвалидируемые кеши**:
|
||||||
|
- `author:follows-topics:{follower_id}` - список подписок на топики
|
||||||
|
- `author:followers:{follower_id}` - счетчики подписчиков
|
||||||
|
- `author:stat:{follower_id}` - общая статистика автора
|
||||||
|
- `topic:followers:{topic_id}` - список подписчиков топика
|
||||||
|
- **Архитектурные принципы**: Разделение ответственности, переиспользуемость, тестируемость
|
||||||
|
|
||||||
## Просмотры публикаций
|
## Просмотры публикаций
|
||||||
|
|
||||||
- Интеграция с Google Analytics для отслеживания просмотров публикаций
|
- Интеграция с Google Analytics для отслеживания просмотров публикаций
|
||||||
|
|
|
@ -42,7 +42,7 @@ Unfollow an entity.
|
||||||
### Queries
|
### Queries
|
||||||
|
|
||||||
#### get_shout_followers
|
#### get_shout_followers
|
||||||
Get list of users who reacted to a shout.
|
Get list of authors who reacted to a shout.
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
- `slug: String` - Shout slug
|
- `slug: String` - Shout slug
|
||||||
|
|
|
@ -34,7 +34,7 @@ JWT_EXPIRATION_HOURS=24
|
||||||
-- Create oauth_links table
|
-- Create oauth_links table
|
||||||
CREATE TABLE oauth_links (
|
CREATE TABLE oauth_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
|
||||||
provider VARCHAR(50) NOT NULL,
|
provider VARCHAR(50) NOT NULL,
|
||||||
provider_id VARCHAR(255) NOT NULL,
|
provider_id VARCHAR(255) NOT NULL,
|
||||||
provider_data JSONB,
|
provider_data JSONB,
|
||||||
|
|
|
@ -295,7 +295,7 @@ async def migrate_oauth_tokens():
|
||||||
refresh_token=author.provider_refresh_token
|
refresh_token=author.provider_refresh_token
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Migrated OAuth tokens for {len(authors)} users")
|
print(f"Migrated OAuth tokens for {len(authors)} authors")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Benefits
|
## Performance Benefits
|
||||||
|
|
|
@ -18,7 +18,7 @@ class CommunityRole(enum.Enum):
|
||||||
ADMIN = "admin"
|
ADMIN = "admin"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_string_array(cls, roles):
|
def as_string_array(cls, roles) -> list[str]:
|
||||||
return [role.value for role in roles]
|
return [role.value for role in roles]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -59,6 +59,7 @@ class Community(BaseModel):
|
||||||
private = Column(Boolean, default=False)
|
private = Column(Boolean, default=False)
|
||||||
|
|
||||||
followers = relationship("Author", secondary="community_follower")
|
followers = relationship("Author", secondary="community_follower")
|
||||||
|
created_by_author = relationship("Author", foreign_keys=[created_by])
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def stat(self):
|
def stat(self):
|
||||||
|
|
4501
package-lock.json
generated
4501
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "admin-panel",
|
"name": "publy-panel",
|
||||||
"version": "0.4.22",
|
"version": "0.5.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
@ -8,20 +8,33 @@
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"lint": "biome check . --fix",
|
"lint": "biome check . --fix",
|
||||||
"format": "biome format . --write",
|
"format": "biome format . --write",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"codegen": "graphql-codegen --config codegen.ts",
|
||||||
|
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^2.0.6",
|
||||||
"@types/node": "^22.15.0",
|
"@graphql-codegen/cli": "^5.0.7",
|
||||||
|
"@graphql-codegen/client-preset": "^4.8.3",
|
||||||
|
"@graphql-codegen/typescript": "^4.0.6",
|
||||||
|
"@graphql-codegen/typescript-operations": "^4.2.0",
|
||||||
|
"@graphql-codegen/typescript-resolvers": "^4.0.6",
|
||||||
|
"@types/node": "^24.0.7",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"graphql": "^16.8.0",
|
"graphql": "^16.11.0",
|
||||||
"solid-js": "^1.9.6",
|
"graphql-tag": "^2.12.6",
|
||||||
|
"lightningcss": "^1.30.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
"solid-js": "^1.9.7",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.0",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-solid": "^2.11.0"
|
"vite-plugin-solid": "^2.11.7"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prismjs": "^1.30.0"
|
"@solidjs/router": "^0.15.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
168
panel/App.tsx
168
panel/App.tsx
|
@ -1,106 +1,90 @@
|
||||||
import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js'
|
import { Route, Router } from '@solidjs/router'
|
||||||
import { isAuthenticated, getAuthTokenFromCookie } from './auth'
|
import { lazy, onMount, Suspense } from 'solid-js'
|
||||||
|
import { AuthProvider, useAuth } from './context/auth'
|
||||||
|
|
||||||
// Ленивая загрузка компонентов
|
// Ленивая загрузка компонентов
|
||||||
const AdminPage = lazy(() => import('./admin'))
|
const AdminPage = lazy(() => {
|
||||||
const LoginPage = lazy(() => import('./login'))
|
console.log('[App] Loading AdminPage component...')
|
||||||
|
return import('./admin')
|
||||||
|
})
|
||||||
|
const LoginPage = lazy(() => {
|
||||||
|
console.log('[App] Loading LoginPage component...')
|
||||||
|
return import('./routes/login')
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Корневой компонент приложения с простой логикой отображения
|
* Компонент защищенного маршрута
|
||||||
*/
|
*/
|
||||||
const App: Component = () => {
|
const ProtectedRoute = () => {
|
||||||
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
|
console.log('[ProtectedRoute] Checking authentication...')
|
||||||
const [loading, setLoading] = createSignal(true)
|
const auth = useAuth()
|
||||||
const [checkingAuth, setCheckingAuth] = createSignal(true)
|
const authenticated = auth.isAuthenticated()
|
||||||
|
console.log(
|
||||||
// Проверяем авторизацию при монтировании
|
`[ProtectedRoute] Authentication state: ${authenticated ? 'authenticated' : 'not authenticated'}`
|
||||||
onMount(() => {
|
)
|
||||||
checkAuthentication()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Периодическая проверка авторизации
|
|
||||||
createEffect(() => {
|
|
||||||
const authCheckInterval = setInterval(() => {
|
|
||||||
// Перепроверяем статус авторизации каждые 60 секунд
|
|
||||||
if (!checkingAuth()) {
|
|
||||||
const authed = isAuthenticated()
|
|
||||||
if (!authed && authenticated()) {
|
|
||||||
console.log('Сессия истекла, требуется повторная авторизация')
|
|
||||||
setAuthenticated(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 60000)
|
|
||||||
|
|
||||||
return () => clearInterval(authCheckInterval)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Функция проверки авторизации
|
|
||||||
const checkAuthentication = async () => {
|
|
||||||
setCheckingAuth(true)
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Проверяем состояние авторизации
|
|
||||||
const authed = isAuthenticated()
|
|
||||||
|
|
||||||
// Если токен есть, но он невалидный, авторизация не удалась
|
|
||||||
if (authed) {
|
|
||||||
const token = getAuthTokenFromCookie() || localStorage.getItem('auth_token')
|
|
||||||
if (!token || token.length < 10) {
|
|
||||||
setAuthenticated(false)
|
|
||||||
} else {
|
|
||||||
setAuthenticated(true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAuthenticated(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при проверке авторизации:', error)
|
|
||||||
setAuthenticated(false)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setCheckingAuth(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработчик успешной авторизации
|
|
||||||
const handleLoginSuccess = () => {
|
|
||||||
setAuthenticated(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработчик выхода из системы
|
|
||||||
const handleLogout = () => {
|
|
||||||
setAuthenticated(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
console.log('[ProtectedRoute] Not authenticated, redirecting to login...')
|
||||||
|
// Используем window.location.href для редиректа
|
||||||
|
window.location.href = '/login'
|
||||||
return (
|
return (
|
||||||
<div class="app-container">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div class="loading-screen">
|
<div class="loading-screen">
|
||||||
<div class="loading-spinner" />
|
<div class="loading-spinner" />
|
||||||
<h2>Загрузка компонентов...</h2>
|
<div>Проверка авторизации...</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!loading()}
|
|
||||||
fallback={
|
|
||||||
<div class="loading-screen">
|
|
||||||
<div class="loading-spinner" />
|
|
||||||
<h2>Проверка авторизации...</h2>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{authenticated() ? (
|
|
||||||
<AdminPage apiUrl={`${location.origin}/graphql`} onLogout={handleLogout} />
|
|
||||||
) : (
|
|
||||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="loading-spinner" />
|
||||||
|
<div>Загрузка админ-панели...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AdminPage apiUrl={`${location.origin}/graphql`} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневой компонент приложения
|
||||||
|
*/
|
||||||
|
const App = () => {
|
||||||
|
console.log('[App] Initializing root component...')
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
console.log('[App] Root component mounted')
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<div class="app-container">
|
||||||
|
<Router>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
component={() => (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="loading-spinner" />
|
||||||
|
<div>Загрузка страницы входа...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LoginPage />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Route path="/" component={ProtectedRoute} />
|
||||||
|
<Route path="/admin" component={ProtectedRoute} />
|
||||||
|
<Route path="/admin/:tab" component={ProtectedRoute} />
|
||||||
|
</Router>
|
||||||
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
1728
panel/admin.tsx
1728
panel/admin.tsx
File diff suppressed because it is too large
Load Diff
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
177
panel/auth.ts
177
panel/auth.ts
|
@ -1,177 +0,0 @@
|
||||||
/**
|
|
||||||
* Модуль авторизации
|
|
||||||
* @module auth
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Экспортируем константы для использования в других модулях
|
|
||||||
export const AUTH_TOKEN_KEY = 'auth_token'
|
|
||||||
export const CSRF_TOKEN_KEY = 'csrf_token'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Интерфейс для учетных данных
|
|
||||||
*/
|
|
||||||
export interface Credentials {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Интерфейс для результата авторизации
|
|
||||||
*/
|
|
||||||
export interface LoginResult {
|
|
||||||
success: boolean
|
|
||||||
token?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Интерфейс для ответа API при логине
|
|
||||||
*/
|
|
||||||
interface LoginResponse {
|
|
||||||
login: LoginResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получает токен авторизации из cookie
|
|
||||||
* @returns Токен или пустую строку, если токен не найден
|
|
||||||
*/
|
|
||||||
export function getAuthTokenFromCookie(): string {
|
|
||||||
const cookieItems = document.cookie.split(';')
|
|
||||||
for (const item of cookieItems) {
|
|
||||||
const [name, value] = item.trim().split('=')
|
|
||||||
if (name === AUTH_TOKEN_KEY) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Получает CSRF-токен из cookie
|
|
||||||
* @returns CSRF-токен или пустую строку, если токен не найден
|
|
||||||
*/
|
|
||||||
export function getCsrfTokenFromCookie(): string {
|
|
||||||
const cookieItems = document.cookie.split(';')
|
|
||||||
for (const item of cookieItems) {
|
|
||||||
const [name, value] = item.trim().split('=')
|
|
||||||
if (name === CSRF_TOKEN_KEY) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет, авторизован ли пользователь
|
|
||||||
* @returns Статус авторизации
|
|
||||||
*/
|
|
||||||
export function isAuthenticated(): boolean {
|
|
||||||
// Проверяем наличие cookie auth_token
|
|
||||||
const cookieToken = getAuthTokenFromCookie()
|
|
||||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
|
||||||
|
|
||||||
// Проверяем наличие токена в localStorage
|
|
||||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
|
||||||
const hasLocalToken = !!localToken && localToken.length > 10
|
|
||||||
|
|
||||||
// Пользователь авторизован, если есть cookie или токен в localStorage
|
|
||||||
return hasCookie || hasLocalToken
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполняет выход из системы
|
|
||||||
* @param callback - Функция обратного вызова после выхода
|
|
||||||
*/
|
|
||||||
export function logout(callback?: () => void): void {
|
|
||||||
// Очищаем токен из localStorage
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
|
||||||
|
|
||||||
// Для удаления cookie устанавливаем ей истекшее время жизни
|
|
||||||
document.cookie = `${AUTH_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
|
||||||
|
|
||||||
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
|
|
||||||
try {
|
|
||||||
fetch('/auth/logout', {
|
|
||||||
method: 'POST', // Используем POST вместо GET для операций изменения состояния
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
|
|
||||||
}
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error('Ошибка при запросе на выход:', e)
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Ошибка при выходе:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вызываем функцию обратного вызова после очистки токенов
|
|
||||||
if (callback) callback()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполняет вход в систему используя GraphQL-запрос
|
|
||||||
* @param credentials - Учетные данные
|
|
||||||
* @returns Результат авторизации
|
|
||||||
*/
|
|
||||||
export async function login(credentials: Credentials): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
console.log('Отправка запроса авторизации через GraphQL')
|
|
||||||
|
|
||||||
const response = await fetch(`${location.origin}/graphql`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
|
|
||||||
},
|
|
||||||
credentials: 'include', // Важно для обработки cookies
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `
|
|
||||||
mutation Login($email: String!, $password: String!) {
|
|
||||||
login(email: $email, password: $password) {
|
|
||||||
success
|
|
||||||
token
|
|
||||||
error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
variables: {
|
|
||||||
email: credentials.email,
|
|
||||||
password: credentials.password
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
console.error('Ошибка HTTP:', response.status, errorText)
|
|
||||||
throw new Error(`HTTP error: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
console.log('Результат авторизации:', result)
|
|
||||||
|
|
||||||
if (result?.data?.login?.success) {
|
|
||||||
// Проверяем, установил ли сервер cookie
|
|
||||||
const cookieToken = getAuthTokenFromCookie()
|
|
||||||
const hasCookie = !!cookieToken && cookieToken.length > 10
|
|
||||||
|
|
||||||
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
|
|
||||||
if (!hasCookie && result.data.login.token) {
|
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, result.data.login.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.errors && result.errors.length > 0) {
|
|
||||||
throw new Error(result.errors[0].message || 'Ошибка авторизации')
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(result?.data?.login?.error || 'Неизвестная ошибка авторизации')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при входе:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
150
panel/context/auth.tsx
Normal file
150
panel/context/auth.tsx
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import { Component, createContext, createSignal, JSX, useContext } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import { ADMIN_LOGIN_MUTATION, ADMIN_LOGOUT_MUTATION } from '../graphql/mutations'
|
||||||
|
import {
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
CSRF_TOKEN_KEY,
|
||||||
|
checkAuthStatus,
|
||||||
|
clearAuthTokens,
|
||||||
|
getAuthTokenFromCookie,
|
||||||
|
getCsrfTokenFromCookie,
|
||||||
|
saveAuthToken
|
||||||
|
} from '../utils/auth'
|
||||||
|
/**
|
||||||
|
* Модуль авторизации
|
||||||
|
* @module auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для учетных данных
|
||||||
|
*/
|
||||||
|
export interface Credentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для результата авторизации
|
||||||
|
*/
|
||||||
|
export interface LoginResult {
|
||||||
|
success: boolean
|
||||||
|
token?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспортируем утилитарные функции для обратной совместимости
|
||||||
|
export {
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
CSRF_TOKEN_KEY,
|
||||||
|
getAuthTokenFromCookie,
|
||||||
|
getCsrfTokenFromCookie,
|
||||||
|
checkAuthStatus,
|
||||||
|
clearAuthTokens,
|
||||||
|
saveAuthToken
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuthenticated: () => boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
isAuthenticated: () => false,
|
||||||
|
login: async () => {},
|
||||||
|
logout: async () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: Component<AuthProviderProps> = (props) => {
|
||||||
|
console.log('[AuthProvider] Initializing...')
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = createSignal(checkAuthStatus())
|
||||||
|
console.log(
|
||||||
|
`[AuthProvider] Initial auth state: ${isAuthenticated() ? 'authenticated' : 'not authenticated'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
console.log('[AuthProvider] Attempting login...')
|
||||||
|
try {
|
||||||
|
const result = await query<{ login: { success: boolean; token?: string } }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_LOGIN_MUTATION,
|
||||||
|
{ email: username, password }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result?.login?.success) {
|
||||||
|
console.log('[AuthProvider] Login successful')
|
||||||
|
if (result.login.token) {
|
||||||
|
saveAuthToken(result.login.token)
|
||||||
|
}
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
// Убираем window.location.href - пусть роутер сам обрабатывает навигацию
|
||||||
|
} else {
|
||||||
|
console.error('[AuthProvider] Login failed')
|
||||||
|
throw new Error('Неверные учетные данные')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AuthProvider] Login error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
console.log('[AuthProvider] Attempting logout...')
|
||||||
|
try {
|
||||||
|
const result = await query<{ logout: { success: boolean } }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_LOGOUT_MUTATION
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result?.logout?.success) {
|
||||||
|
console.log('[AuthProvider] Logout successful')
|
||||||
|
clearAuthTokens()
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AuthProvider] Logout error:', error)
|
||||||
|
// Даже при ошибке очищаем токены и редиректим
|
||||||
|
clearAuthTokens()
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: AuthContextType = {
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AuthProvider] Rendering provider with context')
|
||||||
|
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the logout function for direct use
|
||||||
|
export const logout = async () => {
|
||||||
|
console.log('[Auth] Executing standalone logout...')
|
||||||
|
try {
|
||||||
|
const result = await query<{ logout: { success: boolean } }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_LOGOUT_MUTATION
|
||||||
|
)
|
||||||
|
console.log('[Auth] Standalone logout result:', result)
|
||||||
|
if (result?.logout?.success) {
|
||||||
|
clearAuthTokens()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Standalone logout error:', error)
|
||||||
|
// Даже при ошибке очищаем токены
|
||||||
|
clearAuthTokens()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
208
panel/graphql.ts
208
panel/graphql.ts
|
@ -1,208 +0,0 @@
|
||||||
/**
|
|
||||||
* API-клиент для работы с GraphQL
|
|
||||||
* @module api
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AUTH_TOKEN_KEY, CSRF_TOKEN_KEY, getAuthTokenFromCookie, getCsrfTokenFromCookie } from './auth'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Тип для произвольных данных GraphQL
|
|
||||||
*/
|
|
||||||
type GraphQLData = Record<string, unknown>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обрабатывает ошибки от API
|
|
||||||
* @param response - Ответ от сервера
|
|
||||||
* @returns Обработанный текст ошибки
|
|
||||||
*/
|
|
||||||
async function handleApiError(response: Response): Promise<string> {
|
|
||||||
try {
|
|
||||||
const contentType = response.headers.get('content-type')
|
|
||||||
|
|
||||||
if (contentType?.includes('application/json')) {
|
|
||||||
const errorData = await response.json()
|
|
||||||
|
|
||||||
// Проверяем GraphQL ошибки
|
|
||||||
if (errorData.errors && errorData.errors.length > 0) {
|
|
||||||
return errorData.errors[0].message
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем сообщение об ошибке
|
|
||||||
if (errorData.error || errorData.message) {
|
|
||||||
return errorData.error || errorData.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если не JSON или нет структурированной ошибки, читаем как текст
|
|
||||||
const errorText = await response.text()
|
|
||||||
return `Ошибка сервера: ${response.status} ${response.statusText}. ${errorText.substring(0, 100)}...`
|
|
||||||
} catch (_e) {
|
|
||||||
// Если не можем прочитать ответ
|
|
||||||
return `Ошибка сервера: ${response.status} ${response.statusText}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет наличие ошибок авторизации в ответе GraphQL
|
|
||||||
* @param errors - Массив ошибок GraphQL
|
|
||||||
* @returns true если есть ошибки авторизации
|
|
||||||
*/
|
|
||||||
function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean {
|
|
||||||
return errors.some(
|
|
||||||
(error) =>
|
|
||||||
(error.message &&
|
|
||||||
(error.message.toLowerCase().includes('unauthorized') ||
|
|
||||||
error.message.toLowerCase().includes('авторизации') ||
|
|
||||||
error.message.toLowerCase().includes('authentication') ||
|
|
||||||
error.message.toLowerCase().includes('unauthenticated') ||
|
|
||||||
error.message.toLowerCase().includes('token'))) ||
|
|
||||||
error.extensions?.code === 'UNAUTHENTICATED' ||
|
|
||||||
error.extensions?.code === 'FORBIDDEN'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Подготавливает URL для GraphQL запроса
|
|
||||||
* @param url - URL или путь для запроса
|
|
||||||
* @returns Полный URL для запроса
|
|
||||||
*/
|
|
||||||
function prepareUrl(url: string): string {
|
|
||||||
// В режиме локальной разработки всегда используем /graphql
|
|
||||||
if (location.hostname === 'localhost') {
|
|
||||||
return `${location.origin}/graphql`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если это относительный путь, добавляем к нему origin
|
|
||||||
if (url.startsWith('/')) {
|
|
||||||
return `${location.origin}${url}`
|
|
||||||
}
|
|
||||||
// Если это уже полный URL, используем как есть
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
|
||||||
* @returns Объект с заголовками
|
|
||||||
*/
|
|
||||||
function getRequestHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем наличие токена в localStorage
|
|
||||||
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
|
||||||
|
|
||||||
// Проверяем наличие токена в cookie
|
|
||||||
const cookieToken = getAuthTokenFromCookie()
|
|
||||||
|
|
||||||
// Используем токен из localStorage или cookie
|
|
||||||
const token = localToken || cookieToken
|
|
||||||
|
|
||||||
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
|
||||||
if (token && token.length > 10) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
|
||||||
console.debug('Отправка запроса с токеном авторизации')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем CSRF-токен, если он есть
|
|
||||||
const csrfToken = getCsrfTokenFromCookie()
|
|
||||||
if (csrfToken) {
|
|
||||||
headers['X-CSRF-Token'] = csrfToken
|
|
||||||
console.debug('Добавлен CSRF-токен в запрос')
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполняет GraphQL запрос
|
|
||||||
* @param url - URL для запроса
|
|
||||||
* @param query - GraphQL запрос
|
|
||||||
* @param variables - Переменные запроса
|
|
||||||
* @returns Результат запроса
|
|
||||||
*/
|
|
||||||
export async function query<T = GraphQLData>(
|
|
||||||
url: string,
|
|
||||||
query: string,
|
|
||||||
variables: Record<string, unknown> = {}
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
// Получаем все необходимые заголовки для запроса
|
|
||||||
const headers = getRequestHeaders()
|
|
||||||
|
|
||||||
// Подготавливаем полный URL
|
|
||||||
const fullUrl = prepareUrl(url)
|
|
||||||
console.debug('Отправка GraphQL запроса на:', fullUrl)
|
|
||||||
|
|
||||||
const response = await fetch(fullUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
// Важно: credentials: 'include' - для передачи cookies с запросом
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
query,
|
|
||||||
variables
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Проверяем статус ответа
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = await handleApiError(response)
|
|
||||||
console.error('Ошибка API:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
error: errorMessage
|
|
||||||
})
|
|
||||||
|
|
||||||
// Если получен 401 Unauthorized или 403 Forbidden, перенаправляем на страницу входа
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
|
||||||
window.location.href = '/'
|
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что ответ содержит JSON
|
|
||||||
const contentType = response.headers.get('content-type')
|
|
||||||
if (!contentType?.includes('application/json')) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(`Неверный формат ответа: ${text.substring(0, 100)}...`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.errors) {
|
|
||||||
// Проверяем ошибки на признаки проблем с авторизацией
|
|
||||||
if (hasAuthErrors(result.errors)) {
|
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY)
|
|
||||||
window.location.href = '/'
|
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(result.errors[0].message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data as T
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполняет GraphQL мутацию
|
|
||||||
* @param url - URL для запроса
|
|
||||||
* @param mutation - GraphQL мутация
|
|
||||||
* @param variables - Переменные мутации
|
|
||||||
* @returns Результат мутации
|
|
||||||
*/
|
|
||||||
export function mutate<T = GraphQLData>(
|
|
||||||
url: string,
|
|
||||||
mutation: string,
|
|
||||||
variables: Record<string, unknown> = {}
|
|
||||||
): Promise<T> {
|
|
||||||
return query<T>(url, mutation, variables)
|
|
||||||
}
|
|
139
panel/graphql/index.ts
Normal file
139
panel/graphql/index.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* API-клиент для работы с GraphQL
|
||||||
|
* @module api
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_TOKEN_KEY,
|
||||||
|
clearAuthTokens,
|
||||||
|
getAuthTokenFromCookie,
|
||||||
|
getCsrfTokenFromCookie
|
||||||
|
} from '../utils/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип для произвольных данных GraphQL
|
||||||
|
*/
|
||||||
|
type GraphQLData = Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
|
||||||
|
* @returns Объект с заголовками
|
||||||
|
*/
|
||||||
|
function getRequestHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие токена в localStorage
|
||||||
|
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||||
|
|
||||||
|
// Проверяем наличие токена в cookie
|
||||||
|
const cookieToken = getAuthTokenFromCookie()
|
||||||
|
|
||||||
|
// Используем токен из localStorage или cookie
|
||||||
|
const token = localToken || cookieToken
|
||||||
|
|
||||||
|
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
|
||||||
|
if (token && token.length > 10) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
console.debug('Отправка запроса с токеном авторизации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем CSRF-токен, если он есть
|
||||||
|
const csrfToken = getCsrfTokenFromCookie()
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['X-CSRF-Token'] = csrfToken
|
||||||
|
console.debug('Добавлен CSRF-токен в запрос')
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет GraphQL запрос
|
||||||
|
* @param endpoint - URL эндпоинта GraphQL
|
||||||
|
* @param query - GraphQL запрос
|
||||||
|
* @param variables - Переменные запроса
|
||||||
|
* @returns Результат запроса
|
||||||
|
*/
|
||||||
|
export async function query<T = unknown>(
|
||||||
|
endpoint: string,
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
console.log(`[GraphQL] Making request to ${endpoint}`)
|
||||||
|
console.log(`[GraphQL] Query: ${query.substring(0, 100)}...`)
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[GraphQL] Response status: ${response.status}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.log('[GraphQL] Unauthorized response, clearing auth tokens')
|
||||||
|
clearAuthTokens()
|
||||||
|
// Перенаправляем на страницу входа только если мы не на ней
|
||||||
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`HTTP error: ${response.status} ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('[GraphQL] Response received:', result)
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
// Проверяем ошибки авторизации
|
||||||
|
const hasUnauthorized = result.errors.some(
|
||||||
|
(error: { message?: string }) =>
|
||||||
|
error.message?.toLowerCase().includes('unauthorized') ||
|
||||||
|
error.message?.toLowerCase().includes('please login')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasUnauthorized) {
|
||||||
|
console.log('[GraphQL] Unauthorized error in response, clearing auth tokens')
|
||||||
|
clearAuthTokens()
|
||||||
|
// Перенаправляем на страницу входа только если мы не на ней
|
||||||
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle GraphQL errors
|
||||||
|
const errorMessage = result.errors.map((e: { message?: string }) => e.message).join(', ')
|
||||||
|
throw new Error(`GraphQL error: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GraphQL] Query error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет GraphQL мутацию
|
||||||
|
* @param url - URL для запроса
|
||||||
|
* @param mutation - GraphQL мутация
|
||||||
|
* @param variables - Переменные мутации
|
||||||
|
* @returns Результат мутации
|
||||||
|
*/
|
||||||
|
export function mutate<T = GraphQLData>(
|
||||||
|
url: string,
|
||||||
|
mutation: string,
|
||||||
|
variables: Record<string, unknown> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
return query<T>(url, mutation, variables)
|
||||||
|
}
|
63
panel/graphql/mutations.ts
Normal file
63
panel/graphql/mutations.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
export const ADMIN_LOGIN_MUTATION = `
|
||||||
|
mutation AdminLogin($email: String!, $password: String!) {
|
||||||
|
login(email: $email, password: $password) {
|
||||||
|
success
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ADMIN_LOGOUT_MUTATION = `
|
||||||
|
mutation AdminLogout {
|
||||||
|
logout {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ADMIN_UPDATE_USER_MUTATION = `
|
||||||
|
mutation AdminUpdateUser($user: AdminUserUpdateInput!) {
|
||||||
|
adminUpdateUser(user: $user) {
|
||||||
|
success
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ADMIN_UPDATE_ENV_VARIABLE_MUTATION = `
|
||||||
|
mutation AdminUpdateEnvVariable($key: String!, $value: String!) {
|
||||||
|
updateEnvVariable(key: $key, value: $value)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UPDATE_TOPIC_MUTATION = `
|
||||||
|
mutation UpdateTopic($topic_input: TopicInput!) {
|
||||||
|
update_topic(topic_input: $topic_input) {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const DELETE_TOPIC_MUTATION = `
|
||||||
|
mutation DeleteTopic($id: Int!) {
|
||||||
|
delete_topic_by_id(id: $id) {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UPDATE_COMMUNITY_MUTATION = `
|
||||||
|
mutation UpdateCommunity($community_input: CommunityInput!) {
|
||||||
|
update_community(community_input: $community_input) {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const DELETE_COMMUNITY_MUTATION = `
|
||||||
|
mutation DeleteCommunity($slug: String!) {
|
||||||
|
delete_community(slug: $slug) {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
156
panel/graphql/queries.ts
Normal file
156
panel/graphql/queries.ts
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
|
// Определяем GraphQL запрос
|
||||||
|
export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||||
|
gql`
|
||||||
|
query AdminGetShouts($limit: Int, $offset: Int, $search: String, $status: String) {
|
||||||
|
adminGetShouts(limit: $limit, offset: $offset, search: $search, status: $status) {
|
||||||
|
shouts {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
slug
|
||||||
|
body
|
||||||
|
lead
|
||||||
|
subtitle
|
||||||
|
layout
|
||||||
|
lang
|
||||||
|
cover
|
||||||
|
cover_caption
|
||||||
|
media {
|
||||||
|
url
|
||||||
|
title
|
||||||
|
body
|
||||||
|
source
|
||||||
|
pic
|
||||||
|
date
|
||||||
|
genre
|
||||||
|
artist
|
||||||
|
lyrics
|
||||||
|
}
|
||||||
|
seo
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
published_at
|
||||||
|
featured_at
|
||||||
|
deleted_at
|
||||||
|
created_by {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
}
|
||||||
|
authors {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
topics {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
stat {
|
||||||
|
rating
|
||||||
|
comments_count
|
||||||
|
viewed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
perPage
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`.loc?.source.body || ''
|
||||||
|
|
||||||
|
export const ADMIN_GET_USERS_QUERY: string =
|
||||||
|
gql`
|
||||||
|
query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
|
||||||
|
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
|
||||||
|
authors {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
roles
|
||||||
|
created_at
|
||||||
|
last_seen
|
||||||
|
}
|
||||||
|
total
|
||||||
|
page
|
||||||
|
perPage
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`.loc?.source.body || ''
|
||||||
|
|
||||||
|
export const ADMIN_GET_ROLES_QUERY: string =
|
||||||
|
gql`
|
||||||
|
query AdminGetRoles {
|
||||||
|
adminGetRoles {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`.loc?.source.body || ''
|
||||||
|
|
||||||
|
export const ADMIN_GET_ENV_VARIABLES_QUERY: string =
|
||||||
|
gql`
|
||||||
|
query GetEnvVariables {
|
||||||
|
getEnvVariables {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
variables {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
description
|
||||||
|
type
|
||||||
|
isSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`.loc?.source.body || ''
|
||||||
|
|
||||||
|
export const GET_COMMUNITIES_QUERY: string =
|
||||||
|
gql`
|
||||||
|
query GetCommunities {
|
||||||
|
get_communities_all {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
desc
|
||||||
|
pic
|
||||||
|
created_at
|
||||||
|
created_by {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
stat {
|
||||||
|
shouts
|
||||||
|
followers
|
||||||
|
authors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`.loc?.source.body || ''
|
||||||
|
|
||||||
|
export const GET_TOPICS_QUERY: string =
|
||||||
|
gql`
|
||||||
|
query GetTopics {
|
||||||
|
get_topics_all {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
body
|
||||||
|
pic
|
||||||
|
community
|
||||||
|
parent_ids
|
||||||
|
stat {
|
||||||
|
shouts
|
||||||
|
authors
|
||||||
|
followers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`.loc?.source.body || ''
|
121
panel/login.tsx
121
panel/login.tsx
|
@ -1,121 +0,0 @@
|
||||||
/**
|
|
||||||
* Компонент страницы входа
|
|
||||||
* @module LoginPage
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, createSignal } from 'solid-js'
|
|
||||||
import { login } from './auth'
|
|
||||||
import logo from './publy.svg'
|
|
||||||
|
|
||||||
interface LoginPageProps {
|
|
||||||
onLoginSuccess?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Компонент страницы входа
|
|
||||||
*/
|
|
||||||
const LoginPage: Component<LoginPageProps> = (props) => {
|
|
||||||
const [email, setEmail] = createSignal('')
|
|
||||||
const [password, setPassword] = createSignal('')
|
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
|
||||||
const [formSubmitting, setFormSubmitting] = createSignal(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обработчик отправки формы входа
|
|
||||||
* @param e - Событие отправки формы
|
|
||||||
*/
|
|
||||||
const handleSubmit = async (e: Event) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
// Предотвращаем повторную отправку формы
|
|
||||||
if (formSubmitting()) return
|
|
||||||
|
|
||||||
// Очищаем пробелы в email
|
|
||||||
const cleanEmail = email().trim()
|
|
||||||
|
|
||||||
if (!cleanEmail || !password()) {
|
|
||||||
setError('Пожалуйста, заполните все поля')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormSubmitting(true)
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Используем функцию login из модуля auth
|
|
||||||
const loginSuccessful = await login({
|
|
||||||
email: cleanEmail,
|
|
||||||
password: password()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (loginSuccessful) {
|
|
||||||
// Вызываем коллбэк для оповещения родителя об успешном входе
|
|
||||||
if (props.onLoginSuccess) {
|
|
||||||
props.onLoginSuccess()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('Вход не выполнен')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка при входе:', err)
|
|
||||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
|
|
||||||
setIsLoading(false)
|
|
||||||
} finally {
|
|
||||||
setFormSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="login-page">
|
|
||||||
<div class="login-container">
|
|
||||||
<img src={logo} alt="Logo" />
|
|
||||||
<div class="error-message" style={{ opacity: error() ? 1 : 0 }}>{error()}</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} method="post">
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
placeholder="Email"
|
|
||||||
value={email()}
|
|
||||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
disabled={isLoading()}
|
|
||||||
autocomplete="username"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Пароль"
|
|
||||||
value={password()}
|
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
disabled={isLoading()}
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading() || formSubmitting()}>
|
|
||||||
{isLoading() ? (
|
|
||||||
<>
|
|
||||||
<span class="spinner"></span>
|
|
||||||
Вход...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Войти'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LoginPage
|
|
188
panel/modals/EnvVariableModal.tsx
Normal file
188
panel/modals/EnvVariableModal.tsx
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
import { Component, createMemo, createSignal, Show } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import { EnvVariable } from '../graphql/generated/schema'
|
||||||
|
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
|
||||||
|
import formStyles from '../styles/Form.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import TextPreview from '../ui/TextPreview'
|
||||||
|
|
||||||
|
interface EnvVariableModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
variable: EnvVariable
|
||||||
|
onClose: () => void
|
||||||
|
onSave: () => void
|
||||||
|
onValueChange?: (value: string) => void // FIXME: no need
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
|
||||||
|
const [value, setValue] = createSignal(props.variable.value)
|
||||||
|
const [saving, setSaving] = createSignal(false)
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [showFormatted, setShowFormatted] = createSignal(false)
|
||||||
|
|
||||||
|
// Определяем нужно ли использовать textarea
|
||||||
|
const needsTextarea = createMemo(() => {
|
||||||
|
const val = value()
|
||||||
|
return (
|
||||||
|
val.length > 50 ||
|
||||||
|
val.includes('\n') ||
|
||||||
|
props.variable.type === 'json' ||
|
||||||
|
props.variable.key.includes('URL') ||
|
||||||
|
props.variable.key.includes('SECRET')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Форматируем JSON если возможно
|
||||||
|
const formattedValue = createMemo(() => {
|
||||||
|
if (props.variable.type === 'json' || (value().startsWith('{') && value().endsWith('}'))) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(value()), null, 2)
|
||||||
|
} catch {
|
||||||
|
return value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await query<{ updateEnvVariable: boolean }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_UPDATE_ENV_VARIABLE_MUTATION,
|
||||||
|
{
|
||||||
|
key: props.variable.key,
|
||||||
|
value: value()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result?.updateEnvVariable) {
|
||||||
|
props.onSave()
|
||||||
|
} else {
|
||||||
|
setError('Failed to update environment variable')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = () => {
|
||||||
|
if (props.variable.type === 'json') {
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(JSON.parse(value()), null, 2)
|
||||||
|
setValue(formatted)
|
||||||
|
} catch (_e) {
|
||||||
|
setError('Invalid JSON format')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
title={`Редактировать ${props.variable.key}`}
|
||||||
|
onClose={props.onClose}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div class={formStyles['modal-wide']}>
|
||||||
|
<form class={formStyles.form} onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles['form-label']}>Ключ:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={props.variable.key}
|
||||||
|
disabled
|
||||||
|
class={formStyles['form-input-disabled']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles['form-label']}>
|
||||||
|
Значение:
|
||||||
|
<span class={formStyles['form-label-info']}>
|
||||||
|
{props.variable.type} {props.variable.isSecret && '(секретное)'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Show when={needsTextarea()}>
|
||||||
|
<div class={formStyles['textarea-container']}>
|
||||||
|
<textarea
|
||||||
|
value={value()}
|
||||||
|
onInput={(e) => setValue(e.currentTarget.value)}
|
||||||
|
class={formStyles['form-textarea']}
|
||||||
|
rows={Math.min(Math.max(value().split('\n').length + 2, 4), 15)}
|
||||||
|
placeholder="Введите значение переменной..."
|
||||||
|
/>
|
||||||
|
<Show when={props.variable.type === 'json'}>
|
||||||
|
<div class={formStyles['textarea-actions']}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={formatValue}
|
||||||
|
title="Форматировать JSON"
|
||||||
|
>
|
||||||
|
🎨 Форматировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowFormatted(!showFormatted())}
|
||||||
|
title={showFormatted() ? 'Скрыть превью' : 'Показать превью'}
|
||||||
|
>
|
||||||
|
{showFormatted() ? '👁️ Скрыть' : '👁️ Превью'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!needsTextarea()}>
|
||||||
|
<input
|
||||||
|
type={props.variable.isSecret ? 'password' : 'text'}
|
||||||
|
value={value()}
|
||||||
|
onInput={(e) => setValue(e.currentTarget.value)}
|
||||||
|
class={formStyles['form-input']}
|
||||||
|
placeholder="Введите значение переменной..."
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showFormatted() && (props.variable.type === 'json' || value().startsWith('{'))}>
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles['form-label']}>Превью (форматированное):</label>
|
||||||
|
<div class={formStyles['code-preview-container']}>
|
||||||
|
<TextPreview content={formattedValue()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.variable.description}>
|
||||||
|
<div class={formStyles['form-help']}>
|
||||||
|
<strong>Описание:</strong> {props.variable.description}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class={formStyles['form-error']}>{error()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class={formStyles['form-actions']}>
|
||||||
|
<Button variant="secondary" onClick={props.onClose} disabled={saving()}>
|
||||||
|
Отменить
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave} loading={saving()}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnvVariableModal
|
272
panel/modals/RolesModal.tsx
Normal file
272
panel/modals/RolesModal.tsx
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
import { Component, createEffect, createSignal, For } from 'solid-js'
|
||||||
|
import type { AdminUserInfo } from '../graphql/generated/schema'
|
||||||
|
import styles from '../styles/Form.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
|
||||||
|
export interface UserEditModalProps {
|
||||||
|
user: AdminUserInfo
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (userData: {
|
||||||
|
id: number
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
slug?: string
|
||||||
|
roles: string[]
|
||||||
|
}) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_ROLES = [
|
||||||
|
{ id: 'admin', name: 'Администратор', description: 'Полный доступ к системе' },
|
||||||
|
{ id: 'editor', name: 'Редактор', description: 'Редактирование публикаций и управление сообществом' },
|
||||||
|
{
|
||||||
|
id: 'expert',
|
||||||
|
name: 'Эксперт',
|
||||||
|
description: 'Добавление доказательств и опровержений, управление темами'
|
||||||
|
},
|
||||||
|
{ id: 'author', name: 'Автор', description: 'Создание и редактирование своих публикаций' },
|
||||||
|
{ id: 'reader', name: 'Читатель', description: 'Чтение и комментирование' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const UserEditModal: Component<UserEditModalProps> = (props) => {
|
||||||
|
const [formData, setFormData] = createSignal({
|
||||||
|
email: props.user.email || '',
|
||||||
|
name: props.user.name || '',
|
||||||
|
slug: props.user.slug || '',
|
||||||
|
roles: props.user.roles || []
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||||
|
|
||||||
|
// Сброс формы при открытии модалки
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
setFormData({
|
||||||
|
email: props.user.email || '',
|
||||||
|
name: props.user.name || '',
|
||||||
|
slug: props.user.slug || '',
|
||||||
|
roles: props.user.roles || []
|
||||||
|
})
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
const data = formData()
|
||||||
|
|
||||||
|
// Валидация email
|
||||||
|
if (!data.email.trim()) {
|
||||||
|
newErrors.email = 'Email обязателен'
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||||
|
newErrors.email = 'Некорректный формат email'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация имени
|
||||||
|
if (!data.name.trim()) {
|
||||||
|
newErrors.name = 'Имя обязательно'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация slug
|
||||||
|
if (!data.slug.trim()) {
|
||||||
|
newErrors.slug = 'Slug обязателен'
|
||||||
|
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
|
||||||
|
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация ролей
|
||||||
|
if (data.roles.length === 0) {
|
||||||
|
newErrors.roles = 'Выберите хотя бы одну роль'
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateField = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
|
// Очищаем ошибку для поля при изменении
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: '' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoleToggle = (roleId: string) => {
|
||||||
|
const current = formData().roles
|
||||||
|
const newRoles = current.includes(roleId) ? current.filter((r) => r !== roleId) : [...current, roleId]
|
||||||
|
|
||||||
|
setFormData((prev) => ({ ...prev, roles: newRoles }))
|
||||||
|
setErrors((prev) => ({ ...prev, roles: '' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await props.onSave({
|
||||||
|
id: props.user.id,
|
||||||
|
email: formData().email,
|
||||||
|
name: formData().name,
|
||||||
|
slug: formData().slug,
|
||||||
|
roles: formData().roles
|
||||||
|
})
|
||||||
|
props.onClose()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving user:', error)
|
||||||
|
setErrors({ general: 'Ошибка при сохранении данных пользователя' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (timestamp?: number | null) => {
|
||||||
|
if (!timestamp) return '—'
|
||||||
|
return new Date(timestamp * 1000).toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave} loading={loading()} disabled={loading()}>
|
||||||
|
Сохранить изменения
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Редактирование пользователя #${props.user.id}`}
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
footer={footer}
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
<div class={styles.form}>
|
||||||
|
{errors().general && (
|
||||||
|
<div class={styles.error} style={{ 'margin-bottom': '20px' }}>
|
||||||
|
{errors().general}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Информационная секция */}
|
||||||
|
<div
|
||||||
|
class={styles.section}
|
||||||
|
style={{
|
||||||
|
'margin-bottom': '20px',
|
||||||
|
padding: '15px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
'border-radius': '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 style={{ margin: '0 0 10px 0', color: '#495057' }}>Системная информация</h4>
|
||||||
|
<div style={{ 'font-size': '14px', color: '#6c757d' }}>
|
||||||
|
<div>
|
||||||
|
<strong>ID:</strong> {props.user.id}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Дата регистрации:</strong> {formatDate(props.user.created_at)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Последняя активность:</strong> {formatDate(props.user.last_seen)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основные данные */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>Основные данные</h4>
|
||||||
|
|
||||||
|
<div class={styles.field}>
|
||||||
|
<label for="email" class={styles.label}>
|
||||||
|
Email <span style={{ color: 'red' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
class={`${styles.input} ${errors().email ? styles.inputError : ''}`}
|
||||||
|
value={formData().email}
|
||||||
|
onInput={(e) => updateField('email', e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
{errors().email && <div class={styles.fieldError}>{errors().email}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.field}>
|
||||||
|
<label for="name" class={styles.label}>
|
||||||
|
Имя <span style={{ color: 'red' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
class={`${styles.input} ${errors().name ? styles.inputError : ''}`}
|
||||||
|
value={formData().name}
|
||||||
|
onInput={(e) => updateField('name', e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
placeholder="Иван Иванов"
|
||||||
|
/>
|
||||||
|
{errors().name && <div class={styles.fieldError}>{errors().name}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.field}>
|
||||||
|
<label for="slug" class={styles.label}>
|
||||||
|
Slug (URL) <span style={{ color: 'red' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
type="text"
|
||||||
|
class={`${styles.input} ${errors().slug ? styles.inputError : ''}`}
|
||||||
|
value={formData().slug}
|
||||||
|
onInput={(e) => updateField('slug', e.currentTarget.value.toLowerCase())}
|
||||||
|
disabled={loading()}
|
||||||
|
placeholder="ivan-ivanov"
|
||||||
|
/>
|
||||||
|
<div class={styles.fieldHint}>
|
||||||
|
Используется в URL профиля. Только латинские буквы, цифры, дефисы и подчеркивания.
|
||||||
|
</div>
|
||||||
|
{errors().slug && <div class={styles.fieldError}>{errors().slug}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Роли */}
|
||||||
|
<div class={styles.section}>
|
||||||
|
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>
|
||||||
|
Роли <span style={{ color: 'red' }}>*</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class={styles.rolesGrid}>
|
||||||
|
<For each={AVAILABLE_ROLES}>
|
||||||
|
{(role) => (
|
||||||
|
<label
|
||||||
|
class={`${styles.roleCard} ${formData().roles.includes(role.id) ? styles.roleCardSelected : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData().roles.includes(role.id)}
|
||||||
|
onChange={() => handleRoleToggle(role.id)}
|
||||||
|
disabled={loading()}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<div class={styles.roleHeader}>
|
||||||
|
<span class={styles.roleName}>{role.name}</span>
|
||||||
|
<span class={styles.roleCheckmark}>
|
||||||
|
{formData().roles.includes(role.id) ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class={styles.roleDescription}>{role.description}</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
{errors().roles && <div class={styles.fieldError}>{errors().roles}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserEditModal
|
52
panel/modals/ShoutBodyModal.tsx
Normal file
52
panel/modals/ShoutBodyModal.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { Component, For } from 'solid-js'
|
||||||
|
import type { AdminShoutInfo, Maybe, Topic } from '../graphql/generated/schema'
|
||||||
|
import styles from '../styles/Modal.module.css'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import TextPreview from '../ui/TextPreview'
|
||||||
|
|
||||||
|
export interface ShoutBodyModalProps {
|
||||||
|
shout: AdminShoutInfo
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Просмотр публикации: ${props.shout.title}`}
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div class={styles['shout-body']}>
|
||||||
|
<div class={styles['shout-info']}>
|
||||||
|
<div class={styles['info-row']}>
|
||||||
|
<span class={styles['info-label']}>Автор:</span>
|
||||||
|
<span class={styles['info-value']}>{props.shout?.authors?.[0]?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div class={styles['info-row']}>
|
||||||
|
<span class={styles['info-label']}>Просмотры:</span>
|
||||||
|
<span class={styles['info-value']}>{props.shout.stat?.viewed || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class={styles['info-row']}>
|
||||||
|
<span class={styles['info-label']}>Темы:</span>
|
||||||
|
<div class={styles['topics-list']}>
|
||||||
|
<For each={props.shout?.topics}>
|
||||||
|
{(topic: Maybe<Topic>) => <span class={styles['topic-badge']}>{topic?.title || ''}</span>}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['shout-content']}>
|
||||||
|
<h3>Содержание</h3>
|
||||||
|
<div class={styles['content-preview']}>
|
||||||
|
<TextPreview content={props.shout.body || ''} maxHeight="70vh" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoutBodyModal
|
185
panel/modals/TopicEditModal.tsx
Normal file
185
panel/modals/TopicEditModal.tsx
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
import { Component, createEffect, createSignal } from 'solid-js'
|
||||||
|
import formStyles from '../styles/Form.module.css'
|
||||||
|
import styles from '../styles/Modal.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
pic?: string
|
||||||
|
community: number
|
||||||
|
parent_ids?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicEditModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
topic: Topic | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (topic: Topic) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модальное окно для редактирования топиков
|
||||||
|
*/
|
||||||
|
const TopicEditModal: Component<TopicEditModalProps> = (props) => {
|
||||||
|
const [formData, setFormData] = createSignal<Topic>({
|
||||||
|
id: 0,
|
||||||
|
slug: '',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
pic: '',
|
||||||
|
community: 0,
|
||||||
|
parent_ids: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const [parentIdsText, setParentIdsText] = createSignal('')
|
||||||
|
let bodyRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
// Синхронизация с props.topic
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.topic) {
|
||||||
|
setFormData({ ...props.topic })
|
||||||
|
setParentIdsText(props.topic.parent_ids?.join(', ') || '')
|
||||||
|
|
||||||
|
// Устанавливаем содержимое в contenteditable div
|
||||||
|
if (bodyRef) {
|
||||||
|
bodyRef.innerHTML = props.topic.body || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Парсим parent_ids из строки
|
||||||
|
const parentIds = parentIdsText()
|
||||||
|
.split(',')
|
||||||
|
.map((id) => Number.parseInt(id.trim()))
|
||||||
|
.filter((id) => !Number.isNaN(id))
|
||||||
|
|
||||||
|
const updatedTopic = {
|
||||||
|
...formData(),
|
||||||
|
parent_ids: parentIds.length > 0 ? parentIds : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSave(updatedTopic)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBodyInput = (e: Event) => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
setFormData((prev) => ({ ...prev, body: target.innerHTML }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
onClose={props.onClose}
|
||||||
|
title={`Редактирование топика: ${props.topic?.title || ''}`}
|
||||||
|
>
|
||||||
|
<div class={styles['modal-content']}>
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().id}
|
||||||
|
disabled
|
||||||
|
class={formStyles.input}
|
||||||
|
style={{ background: '#f5f5f5', cursor: 'not-allowed' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>Slug</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().slug}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||||
|
class={formStyles.input}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>Название</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().title}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||||
|
class={formStyles.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>Описание (HTML)</label>
|
||||||
|
<div
|
||||||
|
ref={bodyRef}
|
||||||
|
contentEditable
|
||||||
|
onInput={handleBodyInput}
|
||||||
|
class={formStyles.input}
|
||||||
|
style={{
|
||||||
|
'min-height': '120px',
|
||||||
|
'font-family': 'Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
'font-size': '13px',
|
||||||
|
'line-height': '1.4',
|
||||||
|
'white-space': 'pre-wrap',
|
||||||
|
'overflow-wrap': 'break-word'
|
||||||
|
}}
|
||||||
|
data-placeholder="Введите HTML описание топика..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>Картинка (URL)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().pic || ''}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
|
||||||
|
class={formStyles.input}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>Сообщество (ID)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData().community}
|
||||||
|
onInput={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, community: Number.parseInt(e.target.value) || 0 }))
|
||||||
|
}
|
||||||
|
class={formStyles.input}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={formStyles['form-group']}>
|
||||||
|
<label class={formStyles.label}>
|
||||||
|
Родительские топики (ID через запятую)
|
||||||
|
<small style={{ display: 'block', color: '#666', 'margin-top': '4px' }}>
|
||||||
|
Например: 1, 5, 12
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={parentIdsText()}
|
||||||
|
onInput={(e) => setParentIdsText(e.target.value)}
|
||||||
|
class={formStyles.input}
|
||||||
|
placeholder="1, 5, 12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['modal-actions']}>
|
||||||
|
<Button variant="secondary" onClick={props.onClose}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicEditModal
|
283
panel/routes/authors.tsx
Normal file
283
panel/routes/authors.tsx
Normal file
|
@ -0,0 +1,283 @@
|
||||||
|
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import type { Query, AdminUserInfo as User } from '../graphql/generated/schema'
|
||||||
|
import { ADMIN_UPDATE_USER_MUTATION } from '../graphql/mutations'
|
||||||
|
import { ADMIN_GET_USERS_QUERY } from '../graphql/queries'
|
||||||
|
import UserEditModal from '../modals/RolesModal'
|
||||||
|
import styles from '../styles/Admin.module.css'
|
||||||
|
import Pagination from '../ui/Pagination'
|
||||||
|
import { formatDateRelative } from '../utils/date'
|
||||||
|
|
||||||
|
export interface AuthorsRouteProps {
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onSuccess?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
|
||||||
|
console.log('[AuthorsRoute] Initializing...')
|
||||||
|
const [authors, setUsers] = createSignal<User[]>([])
|
||||||
|
const [loading, setLoading] = createSignal(true)
|
||||||
|
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
|
||||||
|
const [showEditModal, setShowEditModal] = createSignal(false)
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [pagination, setPagination] = createSignal<{
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}>({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка списка пользователей с учетом пагинации и поиска
|
||||||
|
*/
|
||||||
|
async function loadUsers() {
|
||||||
|
console.log('[AuthorsRoute] Loading authors...')
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await query<{ adminGetUsers: Query['adminGetUsers'] }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_GET_USERS_QUERY,
|
||||||
|
{
|
||||||
|
search: searchQuery(),
|
||||||
|
limit: pagination().limit,
|
||||||
|
offset: (pagination().page - 1) * pagination().limit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (data?.adminGetUsers?.authors) {
|
||||||
|
console.log('[AuthorsRoute] Users loaded:', data.adminGetUsers.authors.length)
|
||||||
|
setUsers(data.adminGetUsers.authors)
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: data.adminGetUsers.total || 0,
|
||||||
|
totalPages: data.adminGetUsers.totalPages || 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AuthorsRoute] Failed to load authors:', error)
|
||||||
|
props.onError?.(error instanceof Error ? error.message : 'Failed to load authors')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет данные пользователя (профиль и роли)
|
||||||
|
*/
|
||||||
|
async function updateUser(userData: {
|
||||||
|
id: number
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
slug?: string
|
||||||
|
roles: string[]
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await query(`${location.origin}/graphql`, ADMIN_UPDATE_USER_MUTATION, {
|
||||||
|
user: userData
|
||||||
|
})
|
||||||
|
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((user) => {
|
||||||
|
if (user.id === userData.id) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
email: userData.email || user.email,
|
||||||
|
name: userData.name || user.name,
|
||||||
|
slug: userData.slug || user.slug,
|
||||||
|
roles: userData.roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
closeEditModal()
|
||||||
|
props.onSuccess?.('Данные пользователя успешно обновлены')
|
||||||
|
void loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка обновления пользователя:', err)
|
||||||
|
let errorMessage = err instanceof Error ? err.message : 'Ошибка обновления данных пользователя'
|
||||||
|
|
||||||
|
if (errorMessage.includes('author_role.community')) {
|
||||||
|
errorMessage = 'Ошибка: для роли author требуется указать community. Обратитесь к администратору.'
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onError?.(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
setShowEditModal(false)
|
||||||
|
setSelectedUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination handlers
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
setPagination((prev) => ({ ...prev, page }))
|
||||||
|
void loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePerPageChange(limit: number) {
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||||
|
void loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search handlers
|
||||||
|
function handleSearchChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
setSearchQuery(input.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1 }))
|
||||||
|
void loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load authors on mount
|
||||||
|
onMount(() => {
|
||||||
|
console.log('[AuthorsRoute] Component mounted, loading authors...')
|
||||||
|
void loadUsers()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент для отображения роли с иконкой
|
||||||
|
*/
|
||||||
|
const RoleBadge: Component<{ role: string }> = (props) => {
|
||||||
|
const getRoleIcon = (role: string): string => {
|
||||||
|
switch (role.toLowerCase()) {
|
||||||
|
case 'admin':
|
||||||
|
return '👑'
|
||||||
|
case 'editor':
|
||||||
|
return '✏️'
|
||||||
|
case 'expert':
|
||||||
|
return '🎓'
|
||||||
|
case 'author':
|
||||||
|
return '📝'
|
||||||
|
case 'reader':
|
||||||
|
return '👤'
|
||||||
|
case 'banned':
|
||||||
|
return '🚫'
|
||||||
|
case 'verified':
|
||||||
|
return '✓'
|
||||||
|
default:
|
||||||
|
return '👤'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="role-badge" title={props.role}>
|
||||||
|
<span class="role-icon">{getRoleIcon(props.role)}</span>
|
||||||
|
<span class="role-name">{props.role}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles['authors-container']}>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class={styles['loading']}>Загрузка данных...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading() && authors().length === 0}>
|
||||||
|
<div class={styles['empty-state']}>Нет данных для отображения</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading() && authors().length > 0}>
|
||||||
|
<div class={styles['authors-controls']}>
|
||||||
|
<div class={styles['search-container']}>
|
||||||
|
<div class={styles['search-input-group']}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по email, имени или ID..."
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={handleSearchChange}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
class={styles['search-input']}
|
||||||
|
/>
|
||||||
|
<button class={styles['search-button']} onClick={handleSearch}>
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['authors-list']}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Роли</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={authors()}>
|
||||||
|
{(user) => (
|
||||||
|
<tr>
|
||||||
|
<td>{user.id}</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>{user.name || '-'}</td>
|
||||||
|
<td>{formatDateRelative(user.created_at || Date.now())}</td>
|
||||||
|
<td class={styles['roles-cell']}>
|
||||||
|
<div class={styles['roles-container']}>
|
||||||
|
<For each={Array.from(user.roles || []).filter(Boolean)}>
|
||||||
|
{(role) => <RoleBadge role={role} />}
|
||||||
|
</For>
|
||||||
|
<div
|
||||||
|
class={styles['role-badge edit-role-badge']}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(user)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class={styles['role-icon']}>🎭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination().page}
|
||||||
|
totalPages={pagination().totalPages}
|
||||||
|
total={pagination().total}
|
||||||
|
limit={pagination().limit}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPerPageChange={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={showEditModal() && selectedUser()}>
|
||||||
|
<UserEditModal
|
||||||
|
user={selectedUser()!}
|
||||||
|
isOpen={showEditModal()}
|
||||||
|
onClose={closeEditModal}
|
||||||
|
onSave={updateUser}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthorsRoute
|
381
panel/routes/communities.tsx
Normal file
381
panel/routes/communities.tsx
Normal file
|
@ -0,0 +1,381 @@
|
||||||
|
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
|
import { DELETE_COMMUNITY_MUTATION, UPDATE_COMMUNITY_MUTATION } from '../graphql/mutations'
|
||||||
|
import { GET_COMMUNITIES_QUERY } from '../graphql/queries'
|
||||||
|
import styles from '../styles/Table.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс для сообщества (используем локальный интерфейс для совместимости)
|
||||||
|
*/
|
||||||
|
interface Community {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
desc?: string
|
||||||
|
pic: string
|
||||||
|
created_at: number
|
||||||
|
created_by: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
stat: {
|
||||||
|
shouts: number
|
||||||
|
followers: number
|
||||||
|
authors: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommunitiesRouteProps {
|
||||||
|
onError: (error: string) => void
|
||||||
|
onSuccess: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент для управления сообществами
|
||||||
|
*/
|
||||||
|
const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||||
|
const [communities, setCommunities] = createSignal<Community[]>([])
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [editModal, setEditModal] = createSignal<{ show: boolean; community: Community | null }>({
|
||||||
|
show: false,
|
||||||
|
community: null
|
||||||
|
})
|
||||||
|
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; community: Community | null }>({
|
||||||
|
show: false,
|
||||||
|
community: null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Форма для редактирования
|
||||||
|
const [formData, setFormData] = createSignal({
|
||||||
|
slug: '',
|
||||||
|
name: '',
|
||||||
|
desc: '',
|
||||||
|
pic: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает список всех сообществ
|
||||||
|
*/
|
||||||
|
const loadCommunities = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: GET_COMMUNITIES_QUERY
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(result.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommunities(result.data.get_communities_all || [])
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка загрузки сообществ: ${(error as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует дату
|
||||||
|
*/
|
||||||
|
const formatDate = (timestamp: number): string => {
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает модалку редактирования
|
||||||
|
*/
|
||||||
|
const openEditModal = (community: Community) => {
|
||||||
|
setFormData({
|
||||||
|
slug: community.slug,
|
||||||
|
name: community.name,
|
||||||
|
desc: community.desc || '',
|
||||||
|
pic: community.pic
|
||||||
|
})
|
||||||
|
setEditModal({ show: true, community })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет сообщество
|
||||||
|
*/
|
||||||
|
const updateCommunity = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: UPDATE_COMMUNITY_MUTATION,
|
||||||
|
variables: { community_input: formData() }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(result.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.update_community.error) {
|
||||||
|
throw new Error(result.data.update_community.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSuccess('Сообщество успешно обновлено')
|
||||||
|
setEditModal({ show: false, community: null })
|
||||||
|
await loadCommunities()
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка обновления сообщества: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет сообщество
|
||||||
|
*/
|
||||||
|
const deleteCommunity = async (slug: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: DELETE_COMMUNITY_MUTATION,
|
||||||
|
variables: { slug }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(result.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.delete_community.error) {
|
||||||
|
throw new Error(result.data.delete_community.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onSuccess('Сообщество успешно удалено')
|
||||||
|
setDeleteModal({ show: false, community: null })
|
||||||
|
await loadCommunities()
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка удаления сообщества: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем сообщества при монтировании компонента
|
||||||
|
onMount(() => {
|
||||||
|
void loadCommunities()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.container}>
|
||||||
|
<div class={styles.header}>
|
||||||
|
<h2>Управление сообществами</h2>
|
||||||
|
<Button onClick={loadCommunities} disabled={loading()}>
|
||||||
|
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={!loading()}
|
||||||
|
fallback={
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="loading-spinner" />
|
||||||
|
<div>Загрузка сообществ...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<table class={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Создатель</th>
|
||||||
|
<th>Публикации</th>
|
||||||
|
<th>Подписчики</th>
|
||||||
|
<th>Авторы</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={communities()}>
|
||||||
|
{(community) => (
|
||||||
|
<tr
|
||||||
|
onClick={() => openEditModal(community)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
class={styles['clickable-row']}
|
||||||
|
>
|
||||||
|
<td>{community.id}</td>
|
||||||
|
<td>{community.name}</td>
|
||||||
|
<td>{community.slug}</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
'max-width': '200px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'text-overflow': 'ellipsis',
|
||||||
|
'white-space': 'nowrap'
|
||||||
|
}}
|
||||||
|
title={community.desc}
|
||||||
|
>
|
||||||
|
{community.desc || '—'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{community.created_by.name || community.created_by.email}</td>
|
||||||
|
<td>{community.stat.shouts}</td>
|
||||||
|
<td>{community.stat.followers}</td>
|
||||||
|
<td>{community.stat.authors}</td>
|
||||||
|
<td>{formatDate(community.created_at)}</td>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDeleteModal({ show: true, community })
|
||||||
|
}}
|
||||||
|
class={styles['delete-button']}
|
||||||
|
title="Удалить сообщество"
|
||||||
|
aria-label="Удалить сообщество"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Модальное окно редактирования */}
|
||||||
|
<Modal
|
||||||
|
isOpen={editModal().show}
|
||||||
|
onClose={() => setEditModal({ show: false, community: null })}
|
||||||
|
title={`Редактирование сообщества: ${editModal().community?.name || ''}`}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<div style={{ 'margin-bottom': '16px' }}>
|
||||||
|
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().slug}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '4px'
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ 'margin-bottom': '16px' }}>
|
||||||
|
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().name}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '4px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ 'margin-bottom': '16px' }}>
|
||||||
|
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData().desc}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '4px',
|
||||||
|
'min-height': '80px',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
placeholder="Описание сообщества..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ 'margin-bottom': '16px' }}>
|
||||||
|
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||||
|
Картинка (URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData().pic}
|
||||||
|
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '4px'
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['modal-actions']}>
|
||||||
|
<Button variant="secondary" onClick={() => setEditModal({ show: false, community: null })}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={updateCommunity}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Модальное окно подтверждения удаления */}
|
||||||
|
<Modal
|
||||||
|
isOpen={deleteModal().show}
|
||||||
|
onClose={() => setDeleteModal({ show: false, community: null })}
|
||||||
|
title="Подтверждение удаления"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Вы уверены, что хотите удалить сообщество "<strong>{deleteModal().community?.name}</strong>"?
|
||||||
|
</p>
|
||||||
|
<p class={styles['warning-text']}>
|
||||||
|
Это действие нельзя отменить. Все публикации и темы сообщества могут быть затронуты.
|
||||||
|
</p>
|
||||||
|
<div class={styles['modal-actions']}>
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, community: null })}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => deleteModal().community && deleteCommunity(deleteModal().community!.slug)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommunitiesRoute
|
275
panel/routes/env.tsx
Normal file
275
panel/routes/env.tsx
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
import { Component, createSignal, For, Show } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import type { EnvSection, EnvVariable, Query } from '../graphql/generated/schema'
|
||||||
|
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
|
||||||
|
import { ADMIN_GET_ENV_VARIABLES_QUERY } from '../graphql/queries'
|
||||||
|
import EnvVariableModal from '../modals/EnvVariableModal'
|
||||||
|
import styles from '../styles/Admin.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
|
||||||
|
export interface EnvRouteProps {
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onSuccess?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnvRoute: Component<EnvRouteProps> = (props) => {
|
||||||
|
const [envSections, setEnvSections] = createSignal<EnvSection[]>([])
|
||||||
|
const [loading, setLoading] = createSignal(true)
|
||||||
|
const [editingVariable, setEditingVariable] = createSignal<EnvVariable | null>(null)
|
||||||
|
const [showVariableModal, setShowVariableModal] = createSignal(false)
|
||||||
|
|
||||||
|
// Состояние для показа/скрытия значений
|
||||||
|
const [shownVars, setShownVars] = createSignal<{ [key: string]: boolean }>({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает переменные окружения
|
||||||
|
*/
|
||||||
|
const loadEnvVariables = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await query<{ getEnvVariables: Query['getEnvVariables'] }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_GET_ENV_VARIABLES_QUERY
|
||||||
|
)
|
||||||
|
|
||||||
|
// Важно: пустой массив [] тоже валидный результат!
|
||||||
|
if (result && Array.isArray(result.getEnvVariables)) {
|
||||||
|
setEnvSections(result.getEnvVariables)
|
||||||
|
console.log('Загружено секций переменных:', result.getEnvVariables.length)
|
||||||
|
} else {
|
||||||
|
console.warn('Неожиданный результат от getEnvVariables:', result)
|
||||||
|
setEnvSections([]) // Устанавливаем пустой массив если что-то пошло не так
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load env variables:', error)
|
||||||
|
props.onError?.(error instanceof Error ? error.message : 'Failed to load environment variables')
|
||||||
|
setEnvSections([]) // Устанавливаем пустой массив при ошибке
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет значение переменной окружения
|
||||||
|
*/
|
||||||
|
const updateEnvVariable = async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
const result = await query(`${location.origin}/graphql`, ADMIN_UPDATE_ENV_VARIABLE_MUTATION, {
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result && typeof result === 'object' && 'updateEnvVariable' in result) {
|
||||||
|
props.onSuccess?.(`Переменная ${key} успешно обновлена`)
|
||||||
|
await loadEnvVariables()
|
||||||
|
} else {
|
||||||
|
props.onError?.('Не удалось обновить переменную')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка обновления переменной:', err)
|
||||||
|
props.onError?.(err instanceof Error ? err.message : 'Ошибка при обновлении переменной')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик открытия модального окна редактирования переменной
|
||||||
|
*/
|
||||||
|
const openVariableModal = (variable: EnvVariable) => {
|
||||||
|
setEditingVariable({ ...variable })
|
||||||
|
setShowVariableModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик закрытия модального окна редактирования переменной
|
||||||
|
*/
|
||||||
|
const closeVariableModal = () => {
|
||||||
|
setEditingVariable(null)
|
||||||
|
setShowVariableModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик сохранения переменной
|
||||||
|
*/
|
||||||
|
const saveVariable = async () => {
|
||||||
|
const variable = editingVariable()
|
||||||
|
if (!variable) return
|
||||||
|
|
||||||
|
await updateEnvVariable(variable.key, variable.value)
|
||||||
|
closeVariableModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик изменения значения в модальном окне
|
||||||
|
*/
|
||||||
|
const handleVariableValueChange = (value: string) => {
|
||||||
|
const variable = editingVariable()
|
||||||
|
if (variable) {
|
||||||
|
setEditingVariable({ ...variable, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключает показ значения переменной
|
||||||
|
*/
|
||||||
|
const toggleShow = (key: string) => {
|
||||||
|
setShownVars((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Копирует значение в буфер обмена
|
||||||
|
*/
|
||||||
|
const CopyButton: Component<{ value: string }> = (props) => {
|
||||||
|
const handleCopy = async (e: MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.value)
|
||||||
|
// Можно добавить всплывающее уведомление
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Ошибка копирования: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a class="btn" title="Скопировать" type="button" style="margin-left: 6px" onClick={handleCopy}>
|
||||||
|
📋
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кнопка показать/скрыть значение переменной
|
||||||
|
*/
|
||||||
|
const ShowHideButton: Component<{ shown: boolean; onToggle: () => void }> = (props) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
class="btn"
|
||||||
|
title={props.shown ? 'Скрыть' : 'Показать'}
|
||||||
|
type="button"
|
||||||
|
style="margin-left: 6px"
|
||||||
|
onClick={props.onToggle}
|
||||||
|
>
|
||||||
|
{props.shown ? '🙈' : '👁️'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load env variables on mount
|
||||||
|
void loadEnvVariables()
|
||||||
|
|
||||||
|
// ВРЕМЕННО: для тестирования пустого состояния
|
||||||
|
// setTimeout(() => {
|
||||||
|
// setLoading(false)
|
||||||
|
// setEnvSections([])
|
||||||
|
// console.log('Тест: установлено пустое состояние')
|
||||||
|
// }, 1000)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles['env-variables-container']}>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class={styles['loading']}>Загрузка переменных окружения...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading() && envSections().length === 0}>
|
||||||
|
<div class={styles['empty-state']}>
|
||||||
|
<h3>Переменные окружения не найдены</h3>
|
||||||
|
<p>
|
||||||
|
Переменные окружения не настроены или не обнаружены в системе.
|
||||||
|
<br />
|
||||||
|
Вы можете добавить переменные через файл <code>.env</code> или системные переменные.
|
||||||
|
</p>
|
||||||
|
<details style="margin-top: 16px;">
|
||||||
|
<summary style="cursor: pointer; font-weight: 600;">Как добавить переменные?</summary>
|
||||||
|
<div style="margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
|
||||||
|
<p>
|
||||||
|
<strong>Способ 1:</strong> Через командную строку
|
||||||
|
</p>
|
||||||
|
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
|
||||||
|
export DEBUG=true export DB_URL="postgresql://localhost:5432/db" export
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p style="margin-top: 12px;">
|
||||||
|
<strong>Способ 2:</strong> Через файл .env
|
||||||
|
</p>
|
||||||
|
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
|
||||||
|
DEBUG=true DB_URL=postgresql://localhost:5432/db REDIS_URL=redis://localhost:6379
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading() && envSections().length > 0}>
|
||||||
|
<div class={styles['env-sections']}>
|
||||||
|
<For each={envSections()}>
|
||||||
|
{(section) => (
|
||||||
|
<div class={styles['env-section']}>
|
||||||
|
<h3 class={styles['section-name']}>{section.name}</h3>
|
||||||
|
<Show when={section.description}>
|
||||||
|
<p class={styles['section-description']}>{section.description}</p>
|
||||||
|
</Show>
|
||||||
|
<div class={styles['variables-list']}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ключ</th>
|
||||||
|
<th>Значение</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={section.variables}>
|
||||||
|
{(variable) => {
|
||||||
|
const shown = () => shownVars()[variable.key] || false
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{variable.key}</td>
|
||||||
|
<td>
|
||||||
|
{variable.isSecret && !shown()
|
||||||
|
? '••••••••'
|
||||||
|
: variable.value || <span class={styles['empty-value']}>не задано</span>}
|
||||||
|
<CopyButton value={variable.value || ''} />
|
||||||
|
{variable.isSecret && (
|
||||||
|
<ShowHideButton
|
||||||
|
shown={shown()}
|
||||||
|
onToggle={() => toggleShow(variable.key)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{variable.description || '-'}</td>
|
||||||
|
<td class={styles['actions']}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => openVariableModal(variable)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={editingVariable()}>
|
||||||
|
<EnvVariableModal
|
||||||
|
isOpen={showVariableModal()}
|
||||||
|
variable={editingVariable()!}
|
||||||
|
onClose={closeVariableModal}
|
||||||
|
onSave={saveVariable}
|
||||||
|
onValueChange={handleVariableValueChange}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnvRoute
|
89
panel/routes/login.tsx
Normal file
89
panel/routes/login.tsx
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* Компонент страницы входа
|
||||||
|
* @module LoginPage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
|
import { createSignal, onMount } from 'solid-js'
|
||||||
|
import publyLogo from '../assets/publy.svg?url'
|
||||||
|
import { useAuth } from '../context/auth'
|
||||||
|
import styles from '../styles/Login.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент страницы входа
|
||||||
|
*/
|
||||||
|
const LoginPage = () => {
|
||||||
|
console.log('[LoginPage] Initializing...')
|
||||||
|
const [username, setUsername] = createSignal('')
|
||||||
|
const [password, setPassword] = createSignal('')
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const auth = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
console.log('[LoginPage] Component mounted')
|
||||||
|
// Если пользователь уже авторизован, редиректим на админ-панель
|
||||||
|
if (auth.isAuthenticated()) {
|
||||||
|
console.log('[LoginPage] User already authenticated, redirecting to admin...')
|
||||||
|
navigate('/admin')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.login(username(), password())
|
||||||
|
navigate('/admin')
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Ошибка при входе')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles['login-container']}>
|
||||||
|
<form class={styles['login-form']} onSubmit={handleSubmit}>
|
||||||
|
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
|
||||||
|
<h1>Вход в панель администратора</h1>
|
||||||
|
|
||||||
|
{error() && <div class={styles['error-message']}>{error()}</div>}
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label for="username">Имя пользователя</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username()}
|
||||||
|
onInput={(e) => setUsername(e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['form-group']}>
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password()}
|
||||||
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
disabled={loading()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" disabled={loading()} loading={loading()}>
|
||||||
|
{loading() ? 'Вход...' : 'Войти'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage
|
317
panel/routes/shouts.tsx
Normal file
317
panel/routes/shouts.tsx
Normal file
|
@ -0,0 +1,317 @@
|
||||||
|
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
|
||||||
|
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
|
||||||
|
import styles from '../styles/Admin.module.css'
|
||||||
|
import EditableCodePreview from '../ui/EditableCodePreview'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
import Pagination from '../ui/Pagination'
|
||||||
|
import { formatDateRelative } from '../utils/date'
|
||||||
|
|
||||||
|
export interface ShoutsRouteProps {
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onSuccess?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
|
||||||
|
const [shouts, setShouts] = createSignal<Shout[]>([])
|
||||||
|
const [loading, setLoading] = createSignal(true)
|
||||||
|
const [showBodyModal, setShowBodyModal] = createSignal(false)
|
||||||
|
const [selectedShoutBody, setSelectedShoutBody] = createSignal<string>('')
|
||||||
|
const [showMediaBodyModal, setShowMediaBodyModal] = createSignal(false)
|
||||||
|
const [selectedMediaBody, setSelectedMediaBody] = createSignal<string>('')
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [pagination, setPagination] = createSignal<{
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}>({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка списка публикаций
|
||||||
|
*/
|
||||||
|
async function loadShouts() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
ADMIN_GET_SHOUTS_QUERY,
|
||||||
|
{
|
||||||
|
limit: pagination().limit,
|
||||||
|
offset: (pagination().page - 1) * pagination().limit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (result?.adminGetShouts?.shouts) {
|
||||||
|
setShouts(result.adminGetShouts.shouts)
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
total: result.adminGetShouts.total || 0,
|
||||||
|
totalPages: result.adminGetShouts.totalPages || 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load shouts:', error)
|
||||||
|
props.onError?.(error instanceof Error ? error.message : 'Failed to load shouts')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load shouts on mount
|
||||||
|
onMount(() => {
|
||||||
|
void loadShouts()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pagination handlers
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
setPagination((prev) => ({ ...prev, page }))
|
||||||
|
void loadShouts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePerPageChange(limit: number) {
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||||
|
void loadShouts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function getShoutStatus(shout: Shout): string {
|
||||||
|
if (shout.deleted_at) return '🗑️'
|
||||||
|
if (shout.published_at) return '✅'
|
||||||
|
return '📝'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShoutStatusTitle(shout: Shout): string {
|
||||||
|
if (shout.deleted_at) return 'Удалена'
|
||||||
|
if (shout.published_at) return 'Опубликована'
|
||||||
|
return 'Черновик'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShoutStatusClass(shout: Shout): string {
|
||||||
|
if (shout.deleted_at) return 'status-deleted'
|
||||||
|
if (shout.published_at) return 'status-published'
|
||||||
|
return 'status-draft'
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(text: string, maxLength = 100): string {
|
||||||
|
if (!text || text.length <= maxLength) return text
|
||||||
|
return `${text.substring(0, maxLength)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles['shouts-container']}>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class={styles['loading']}>Загрузка публикаций...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading() && shouts().length === 0}>
|
||||||
|
<div class={styles['empty-state']}>Нет публикаций для отображения</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading() && shouts().length > 0}>
|
||||||
|
<div class={styles['shouts-controls']}>
|
||||||
|
<div class={styles['search-container']}>
|
||||||
|
<div class={styles['search-input-group']}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по заголовку, slug или ID..."
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
void loadShouts()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class={styles['search-input']}
|
||||||
|
/>
|
||||||
|
<button class={styles['search-button']} onClick={() => void loadShouts()}>
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['shouts-list']}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Заголовок</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Авторы</th>
|
||||||
|
<th>Темы</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Содержимое</th>
|
||||||
|
<th>Media</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={shouts()}>
|
||||||
|
{(shout) => (
|
||||||
|
<tr>
|
||||||
|
<td>{shout.id}</td>
|
||||||
|
<td title={shout.title}>{truncateText(shout.title, 50)}</td>
|
||||||
|
<td title={shout.slug}>{truncateText(shout.slug, 30)}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class={`${styles['status-badge']} ${getShoutStatusClass(shout)}`}
|
||||||
|
title={getShoutStatusTitle(shout)}
|
||||||
|
>
|
||||||
|
{getShoutStatus(shout)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show when={shout.authors?.length}>
|
||||||
|
<div class={styles['authors-list']}>
|
||||||
|
<For each={shout.authors}>
|
||||||
|
{(author) => (
|
||||||
|
<Show when={author}>
|
||||||
|
{(safeAuthor) => (
|
||||||
|
<span class={styles['author-badge']} title={safeAuthor()?.email || ''}>
|
||||||
|
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!shout.authors?.length}>
|
||||||
|
<span class={styles['no-data']}>-</span>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show when={shout.topics?.length}>
|
||||||
|
<div class={styles['topics-list']}>
|
||||||
|
<For each={shout.topics}>
|
||||||
|
{(topic) => (
|
||||||
|
<Show when={topic}>
|
||||||
|
{(safeTopic) => (
|
||||||
|
<span class={styles['topic-badge']} title={safeTopic()?.slug || ''}>
|
||||||
|
{safeTopic()?.title || safeTopic()?.slug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!shout.topics?.length}>
|
||||||
|
<span class={styles['no-data']}>-</span>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td>{formatDateRelative(shout.created_at)}</td>
|
||||||
|
<td
|
||||||
|
class={styles['body-cell']}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShoutBody(shout.body)
|
||||||
|
setShowBodyModal(true)
|
||||||
|
}}
|
||||||
|
style="cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||||
|
>
|
||||||
|
{truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show when={shout.media && shout.media.length > 0}>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<For each={shout.media}>
|
||||||
|
{(mediaItem, idx) => (
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span class={styles['media-count']}>
|
||||||
|
{mediaItem?.title || `media[${idx()}]`}
|
||||||
|
</span>
|
||||||
|
<Show when={mediaItem?.body}>
|
||||||
|
<button
|
||||||
|
class={styles['edit-button']}
|
||||||
|
style="padding: 2px 8px; font-size: 12px;"
|
||||||
|
title="Показать содержимое body"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMediaBody(mediaItem?.body || '')
|
||||||
|
setShowMediaBodyModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👁 body
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!shout.media || shout.media.length === 0}>
|
||||||
|
<span class={styles['no-data']}>-</span>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination().page}
|
||||||
|
totalPages={pagination().totalPages}
|
||||||
|
total={pagination().total}
|
||||||
|
limit={pagination().limit}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPerPageChange={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Modal isOpen={showBodyModal()} onClose={() => setShowBodyModal(false)} title="Содержимое публикации">
|
||||||
|
<EditableCodePreview
|
||||||
|
content={selectedShoutBody()}
|
||||||
|
maxHeight="70vh"
|
||||||
|
onContentChange={(newContent) => {
|
||||||
|
setSelectedShoutBody(newContent)
|
||||||
|
}}
|
||||||
|
onSave={(_content) => {
|
||||||
|
// FIXME: добавить логику сохранения изменений в базу данных
|
||||||
|
props.onSuccess?.('Содержимое публикации обновлено')
|
||||||
|
setShowBodyModal(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowBodyModal(false)
|
||||||
|
}}
|
||||||
|
placeholder="Введите содержимое публикации..."
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showMediaBodyModal()}
|
||||||
|
onClose={() => setShowMediaBodyModal(false)}
|
||||||
|
title="Содержимое media.body"
|
||||||
|
>
|
||||||
|
<EditableCodePreview
|
||||||
|
content={selectedMediaBody()}
|
||||||
|
maxHeight="70vh"
|
||||||
|
onContentChange={(newContent) => {
|
||||||
|
setSelectedMediaBody(newContent)
|
||||||
|
}}
|
||||||
|
onSave={(_content) => {
|
||||||
|
// FIXME: добавить логику сохранения изменений media.body
|
||||||
|
props.onSuccess?.('Содержимое media.body обновлено')
|
||||||
|
setShowMediaBodyModal(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowMediaBodyModal(false)
|
||||||
|
}}
|
||||||
|
placeholder="Введите содержимое media.body..."
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoutsRoute
|
410
panel/routes/topics.tsx
Normal file
410
panel/routes/topics.tsx
Normal file
|
@ -0,0 +1,410 @@
|
||||||
|
/**
|
||||||
|
* Компонент управления топиками
|
||||||
|
* @module TopicsRoute
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js'
|
||||||
|
import { query } from '../graphql'
|
||||||
|
import type { Query } from '../graphql/generated/schema'
|
||||||
|
import { DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||||
|
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||||
|
import TopicEditModal from '../modals/TopicEditModal'
|
||||||
|
import styles from '../styles/Table.module.css'
|
||||||
|
import Button from '../ui/Button'
|
||||||
|
import Modal from '../ui/Modal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс топика
|
||||||
|
*/
|
||||||
|
interface Topic {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
pic?: string
|
||||||
|
community: number
|
||||||
|
parent_ids?: number[]
|
||||||
|
children?: Topic[]
|
||||||
|
level?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интерфейс свойств компонента
|
||||||
|
*/
|
||||||
|
interface TopicsRouteProps {
|
||||||
|
onError: (error: string) => void
|
||||||
|
onSuccess: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент управления топиками
|
||||||
|
*/
|
||||||
|
const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
|
const [rawTopics, setRawTopics] = createSignal<Topic[]>([])
|
||||||
|
const [topics, setTopics] = createSignal<Topic[]>([])
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [sortBy, setSortBy] = createSignal<'id' | 'title'>('id')
|
||||||
|
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc')
|
||||||
|
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; topic: Topic | null }>({
|
||||||
|
show: false,
|
||||||
|
topic: null
|
||||||
|
})
|
||||||
|
const [editModal, setEditModal] = createSignal<{ show: boolean; topic: Topic | null }>({
|
||||||
|
show: false,
|
||||||
|
topic: null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает список всех топиков
|
||||||
|
*/
|
||||||
|
const loadTopics = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await query<{ get_topics_all: Query['get_topics_all'] }>(
|
||||||
|
`${location.origin}/graphql`,
|
||||||
|
GET_TOPICS_QUERY
|
||||||
|
)
|
||||||
|
|
||||||
|
if (data?.get_topics_all) {
|
||||||
|
// Строим иерархическую структуру
|
||||||
|
const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null)
|
||||||
|
setRawTopics(validTopics)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка загрузки топиков: ${(error as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересортировка при изменении rawTopics или параметров сортировки
|
||||||
|
createEffect(
|
||||||
|
on([rawTopics, sortBy, sortDirection], () => {
|
||||||
|
const rawData = rawTopics()
|
||||||
|
const sort = sortBy()
|
||||||
|
const direction = sortDirection()
|
||||||
|
|
||||||
|
if (rawData.length > 0) {
|
||||||
|
// Используем untrack для чтения buildHierarchy без дополнительных зависимостей
|
||||||
|
const hierarchicalTopics = untrack(() => buildHierarchy(rawData, sort, direction))
|
||||||
|
setTopics(hierarchicalTopics)
|
||||||
|
} else {
|
||||||
|
setTopics([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Загружаем топики при монтировании компонента
|
||||||
|
onMount(() => {
|
||||||
|
void loadTopics()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит иерархическую структуру топиков
|
||||||
|
*/
|
||||||
|
const buildHierarchy = (
|
||||||
|
flatTopics: Topic[],
|
||||||
|
sortField?: 'id' | 'title',
|
||||||
|
sortDir?: 'asc' | 'desc'
|
||||||
|
): Topic[] => {
|
||||||
|
const topicMap = new Map<number, Topic>()
|
||||||
|
const rootTopics: Topic[] = []
|
||||||
|
|
||||||
|
// Создаем карту всех топиков
|
||||||
|
flatTopics.forEach((topic) => {
|
||||||
|
topicMap.set(topic.id, { ...topic, children: [], level: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Строим иерархию
|
||||||
|
flatTopics.forEach((topic) => {
|
||||||
|
const currentTopic = topicMap.get(topic.id)!
|
||||||
|
|
||||||
|
if (!topic.parent_ids || topic.parent_ids.length === 0) {
|
||||||
|
// Корневой топик
|
||||||
|
rootTopics.push(currentTopic)
|
||||||
|
} else {
|
||||||
|
// Находим родителя и добавляем как дочерний
|
||||||
|
const parentId = topic.parent_ids[topic.parent_ids.length - 1]
|
||||||
|
const parent = topicMap.get(parentId)
|
||||||
|
if (parent) {
|
||||||
|
currentTopic.level = (parent.level || 0) + 1
|
||||||
|
parent.children!.push(currentTopic)
|
||||||
|
} else {
|
||||||
|
// Если родитель не найден, добавляем как корневой
|
||||||
|
rootTopics.push(currentTopic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sortTopics(rootTopics, sortField, sortDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сортирует топики рекурсивно
|
||||||
|
*/
|
||||||
|
const sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => {
|
||||||
|
const field = sortField || sortBy()
|
||||||
|
const direction = sortDir || sortDirection()
|
||||||
|
|
||||||
|
const sortedTopics = topics.sort((a, b) => {
|
||||||
|
let comparison = 0
|
||||||
|
|
||||||
|
if (field === 'title') {
|
||||||
|
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
|
||||||
|
} else {
|
||||||
|
comparison = a.id - b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'desc' ? -comparison : comparison
|
||||||
|
})
|
||||||
|
|
||||||
|
// Рекурсивно сортируем дочерние элементы
|
||||||
|
sortedTopics.forEach((topic) => {
|
||||||
|
if (topic.children && topic.children.length > 0) {
|
||||||
|
topic.children = sortTopics(topic.children, field, direction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return sortedTopics
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрезает текст до указанной длины
|
||||||
|
*/
|
||||||
|
const truncateText = (text: string, maxLength = 100): string => {
|
||||||
|
if (!text) return '—'
|
||||||
|
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рекурсивно отображает топики с отступами для иерархии
|
||||||
|
*/
|
||||||
|
const renderTopics = (topics: Topic[]): JSX.Element[] => {
|
||||||
|
const result: JSX.Element[] = []
|
||||||
|
|
||||||
|
topics.forEach((topic) => {
|
||||||
|
result.push(
|
||||||
|
<tr
|
||||||
|
onClick={() => setEditModal({ show: true, topic })}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
class={styles['clickable-row']}
|
||||||
|
>
|
||||||
|
<td>{topic.id}</td>
|
||||||
|
<td style={{ 'padding-left': `${(topic.level || 0) * 20}px` }}>
|
||||||
|
{topic.level! > 0 && '└─ '}
|
||||||
|
{topic.title}
|
||||||
|
</td>
|
||||||
|
<td>{topic.slug}</td>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
'max-width': '200px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'text-overflow': 'ellipsis',
|
||||||
|
'white-space': 'nowrap'
|
||||||
|
}}
|
||||||
|
title={topic.body}
|
||||||
|
>
|
||||||
|
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{topic.community}</td>
|
||||||
|
<td>{topic.parent_ids?.join(', ') || '—'}</td>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDeleteModal({ show: true, topic })
|
||||||
|
}}
|
||||||
|
class={styles['delete-button']}
|
||||||
|
title="Удалить топик"
|
||||||
|
aria-label="Удалить топик"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (topic.children && topic.children.length > 0) {
|
||||||
|
result.push(...renderTopics(topic.children))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет топик
|
||||||
|
*/
|
||||||
|
const updateTopic = async (updatedTopic: Topic) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: UPDATE_TOPIC_MUTATION,
|
||||||
|
variables: { topic_input: updatedTopic }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(result.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.update_topic.success) {
|
||||||
|
props.onSuccess('Топик успешно обновлен')
|
||||||
|
setEditModal({ show: false, topic: null })
|
||||||
|
await loadTopics() // Перезагружаем список
|
||||||
|
} else {
|
||||||
|
throw new Error(result.data.update_topic.message || 'Ошибка обновления топика')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка обновления топика: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет топик
|
||||||
|
*/
|
||||||
|
const deleteTopic = async (topicId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/graphql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: DELETE_TOPIC_MUTATION,
|
||||||
|
variables: { id: topicId }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
throw new Error(result.errors[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.delete_topic_by_id.success) {
|
||||||
|
props.onSuccess('Топик успешно удален')
|
||||||
|
setDeleteModal({ show: false, topic: null })
|
||||||
|
await loadTopics() // Перезагружаем список
|
||||||
|
} else {
|
||||||
|
throw new Error(result.data.delete_topic_by_id.message || 'Ошибка удаления топика')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
props.onError(`Ошибка удаления топика: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.container}>
|
||||||
|
<div class={styles.header}>
|
||||||
|
<h2>Управление топиками</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
||||||
|
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
|
||||||
|
<select
|
||||||
|
value={sortBy()}
|
||||||
|
onInput={(e) => setSortBy(e.target.value as 'id' | 'title')}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '4px',
|
||||||
|
'font-size': '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="id">По ID</option>
|
||||||
|
<option value="title">По названию</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={sortDirection()}
|
||||||
|
onInput={(e) => setSortDirection(e.target.value as 'asc' | 'desc')}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
'border-radius': '4px',
|
||||||
|
'font-size': '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="asc">↑ По возрастанию</option>
|
||||||
|
<option value="desc">↓ По убыванию</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={loadTopics} disabled={loading()}>
|
||||||
|
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={!loading()}
|
||||||
|
fallback={
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="loading-spinner" />
|
||||||
|
<div>Загрузка топиков...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<table class={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Сообщество</th>
|
||||||
|
<th>Родители</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={renderTopics(topics())}>{(row) => row}</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Модальное окно редактирования */}
|
||||||
|
<TopicEditModal
|
||||||
|
isOpen={editModal().show}
|
||||||
|
topic={editModal().topic}
|
||||||
|
onClose={() => setEditModal({ show: false, topic: null })}
|
||||||
|
onSave={updateTopic}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модальное окно подтверждения удаления */}
|
||||||
|
<Modal
|
||||||
|
isOpen={deleteModal().show}
|
||||||
|
onClose={() => setDeleteModal({ show: false, topic: null })}
|
||||||
|
title="Подтверждение удаления"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
|
||||||
|
</p>
|
||||||
|
<p class={styles['warning-text']}>
|
||||||
|
Это действие нельзя отменить. Все дочерние топики также будут удалены.
|
||||||
|
</p>
|
||||||
|
<div class={styles['modal-actions']}>
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => deleteModal().topic && deleteTopic(deleteModal().topic!.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicsRoute
|
266
panel/styles.css
266
panel/styles.css
|
@ -1,44 +1,73 @@
|
||||||
/**
|
/**
|
||||||
* Основные стили приложения
|
* Global Styles and CSS Variables
|
||||||
|
* Minimal global styling with focus on CSS variables and reset
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Сброс стилей */
|
/* Global Styles */
|
||||||
|
@import './styles/GlobalVariables.module.css';
|
||||||
|
|
||||||
|
/* CSS Reset and Base Styles */
|
||||||
* {
|
* {
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, html {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimal Accessibility and Utility Styles */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Typography */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: none;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Общие стили */
|
/* Общие стили */
|
||||||
:root {
|
:root {
|
||||||
/* Основные цвета */
|
|
||||||
--primary-color: #000000;
|
|
||||||
--primary-dark: #333333;
|
|
||||||
--primary-light: #F5F5F5;
|
|
||||||
|
|
||||||
/* Статусные цвета */
|
|
||||||
--success-color: #155724;
|
|
||||||
--success-light: #d4edda;
|
|
||||||
--success-border: #c3e6cb;
|
|
||||||
|
|
||||||
--danger-color: #721c24;
|
|
||||||
--danger-light: #f8d7da;
|
|
||||||
--danger-border: #f5c6cb;
|
|
||||||
|
|
||||||
--warning-color: #856404;
|
|
||||||
--warning-light: #fff3cd;
|
|
||||||
--warning-border: #ffeaa7;
|
|
||||||
|
|
||||||
/* Текст и фон */
|
|
||||||
--text-color: #000000;
|
|
||||||
--text-secondary: #666666;
|
|
||||||
--text-muted: #6b7280;
|
|
||||||
--bg-color: #FFFFFF;
|
|
||||||
--card-bg: #FFFFFF;
|
|
||||||
|
|
||||||
/* Границы и тени */
|
/* Границы и тени */
|
||||||
--border-color: #E0E0E0;
|
--border-color: #E0E0E0;
|
||||||
--border-radius-sm: 4px;
|
--border-radius-sm: 4px;
|
||||||
|
@ -52,11 +81,10 @@
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||||
|
|
||||||
/* Размеры */
|
/* Размеры */
|
||||||
--container-max-width: 1200px;
|
--container-max-width: 1400px;
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
|
|
||||||
/* Анимации */
|
/* Анимации */
|
||||||
--transition-fast: 0.2s ease;
|
|
||||||
--transition-normal: 0.3s ease;
|
--transition-normal: 0.3s ease;
|
||||||
|
|
||||||
/* Z-индексы */
|
/* Z-индексы */
|
||||||
|
@ -83,29 +111,34 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Общие элементы интерфейса */
|
/* Общие элементы интерфейса */
|
||||||
.loading-screen, .loading {
|
.loading-screen {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 200px;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
background-color: var(--background-color);
|
||||||
text-align: center;
|
color: var(--text-color-light);
|
||||||
color: var(--primary-color);
|
font-size: var(--font-size-lg);
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
width: 2rem;
|
||||||
border-left-color: var(--primary-color);
|
height: 2rem;
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 40px;
|
border-top-color: var(--primary-color);
|
||||||
height: 40px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
|
@ -168,34 +201,27 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius-md);
|
background: none;
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-fast);
|
padding: 0;
|
||||||
width: 100%;
|
margin: 0;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:focus,
|
||||||
background-color: var(--primary-dark);
|
input:focus,
|
||||||
transform: translateY(-1px);
|
select:focus,
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled,
|
||||||
background-color: #E5E9F2;
|
input:disabled,
|
||||||
color: #A0AEC0;
|
select:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Стили для страницы входа */
|
/* Стили для страницы входа */
|
||||||
|
@ -329,7 +355,7 @@ header h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 20px;
|
padding: 1.5rem 3rem;
|
||||||
max-width: var(--container-max-width);
|
max-width: var(--container-max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -337,7 +363,7 @@ main {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Таблица пользователей */
|
/* Таблица пользователей */
|
||||||
.users-list {
|
.authors-list {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
@ -351,6 +377,7 @@ table {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
|
min-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
|
@ -358,10 +385,11 @@ thead {
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 14px 16px;
|
padding: 18px 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
|
@ -369,7 +397,7 @@ th {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background-color: #F5F7FA;
|
background-color: #F5F7FA;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -710,12 +738,12 @@ tr:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Поиск */
|
/* Поиск */
|
||||||
.users-controls {
|
.authors-controls {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -771,7 +799,7 @@ tr:hover {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-list {
|
.authors-list {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1117,7 +1145,7 @@ th.sortable.sorted .sort-icon {
|
||||||
padding: 8px 5px;
|
padding: 8px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-list,
|
.authors-list,
|
||||||
.shouts-list table {
|
.shouts-list table {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
@ -1385,99 +1413,7 @@ button:hover,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Оптимизация для доступности */
|
/* Оптимизация для доступности */
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.focus-visible:focus-visible {
|
.focus-visible:focus-visible {
|
||||||
outline: 2px solid var(--primary-color);
|
outline: 2px solid var(--primary-color);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Убираем скругления и делаем строгий стиль для пагинации и кнопок */
|
|
||||||
button,
|
|
||||||
.pagination,
|
|
||||||
.pagination-button,
|
|
||||||
.pagination-per-page select {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
background: #ededed;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-button {
|
|
||||||
min-width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
background: #181818;
|
|
||||||
color: #fff;
|
|
||||||
border: 1px solid #222;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
transition: background 0.15s, color 0.15s, border 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-button.active {
|
|
||||||
background: #fff;
|
|
||||||
color: #111;
|
|
||||||
border: 2px solid #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-button:hover:not(:disabled) {
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #111;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-button:disabled {
|
|
||||||
background: #aaa;
|
|
||||||
color: #fff;
|
|
||||||
opacity: 0.5;
|
|
||||||
border-color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-ellipsis {
|
|
||||||
background: transparent;
|
|
||||||
color: #888;
|
|
||||||
min-width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: none;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-per-page select {
|
|
||||||
background: #181818;
|
|
||||||
color: #fff;
|
|
||||||
border: 1px solid #222;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
544
panel/styles/Admin.module.css
Normal file
544
panel/styles/Admin.module.css
Normal file
|
@ -0,0 +1,544 @@
|
||||||
|
/* Admin Panel Layout */
|
||||||
|
.admin-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background-color: var(--header-background);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 2rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background-color: var(--header-background);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem 3rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common Styles */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state details {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state summary:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state pre {
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 1rem 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--error-color-light);
|
||||||
|
color: var(--error-color-dark);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
margin: 1rem 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--success-color-light);
|
||||||
|
color: var(--success-color-dark);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Users Route Styles */
|
||||||
|
.authors-container {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-controls {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button:hover {
|
||||||
|
background-color: var(--primary-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list th,
|
||||||
|
.authors-list td {
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list th {
|
||||||
|
background-color: var(--header-background);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list tr:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-cell {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: var(--secondary-color-light);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-icon {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-badge {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--primary-color-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-role-badge:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shouts Route Styles */
|
||||||
|
.shouts-container {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shouts-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filter select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shouts-list {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-published {
|
||||||
|
background-color: var(--success-color-light);
|
||||||
|
color: var(--success-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-draft {
|
||||||
|
background-color: var(--warning-color-light);
|
||||||
|
color: var(--warning-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.status-deleted {
|
||||||
|
background-color: var(--error-color-light);
|
||||||
|
color: var(--error-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--success-color-light);
|
||||||
|
color: var(--success-color-dark);
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--info-color-light);
|
||||||
|
color: var(--info-color-dark);
|
||||||
|
margin: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-cell:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
color: var(--text-color-light);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Environment Variables Route Styles */
|
||||||
|
.env-variables-container {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-section {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-name {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variables-list {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-value {
|
||||||
|
color: var(--text-color-light);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Styles */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 900px;
|
||||||
|
table-layout: fixed; /* Фиксированная ширина столбцов */
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap; /* Заголовки не переносятся */
|
||||||
|
overflow: hidden;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word; /* Перенос длинных слов */
|
||||||
|
white-space: normal; /* Разрешаем перенос строк */
|
||||||
|
vertical-align: top; /* Выравнивание по верхнему краю */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Специальные стили для колонок публикаций */
|
||||||
|
.shouts-list th:nth-child(1) { width: 4%; } /* ID */
|
||||||
|
.shouts-list th:nth-child(2) { width: 24%; } /* ЗАГОЛОВОК */
|
||||||
|
.shouts-list th:nth-child(3) { width: 14%; } /* SLUG */
|
||||||
|
.shouts-list th:nth-child(4) { width: 8%; } /* СТАТУС */
|
||||||
|
.shouts-list th:nth-child(5) { width: 10%; } /* АВТОРЫ */
|
||||||
|
.shouts-list th:nth-child(6) { width: 10%; } /* ТЕМЫ */
|
||||||
|
.shouts-list th:nth-child(7) { width: 10%; } /* СОЗДАН */
|
||||||
|
.shouts-list th:nth-child(8) { width: 10%; } /* СОДЕРЖИМОЕ */
|
||||||
|
.shouts-list th:nth-child(9) { width: 10%; } /* MEDIA */
|
||||||
|
|
||||||
|
/* Компактные стили для колонки ID */
|
||||||
|
.shouts-list th:nth-child(1),
|
||||||
|
.shouts-list td:nth-child(1) {
|
||||||
|
padding: 0.6rem 0.4rem !important;
|
||||||
|
font-size: 0.7rem !important;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shouts-list td:nth-child(8) { /* Колонка содержимого */
|
||||||
|
max-width: 200px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Styles */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.header-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
padding: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-container,
|
||||||
|
.shouts-container,
|
||||||
|
.env-variables-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shouts-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-filter select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.header-container {
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list {
|
||||||
|
margin: 0 -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list table {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authors-list th,
|
||||||
|
.authors-list td {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
94
panel/styles/Button.module.css
Normal file
94
panel/styles/Button.module.css
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
.button-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: var(--secondary-color-light);
|
||||||
|
color: var(--secondary-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover:not(:disabled) {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover:not(:disabled) {
|
||||||
|
background-color: var(--error-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sizes */
|
||||||
|
.button-small {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-medium {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-large {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-loading {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-right-color: transparent;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
138
panel/styles/CodePreview.module.css
Normal file
138
panel/styles/CodePreview.module.css
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
.codePreview {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 50px !important;
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
color: #f8f8f2;
|
||||||
|
tab-size: 2;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineNumber {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
color: #999;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding-right: 10px;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
background-color: rgba(0,0,0,0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для EditableCodePreview */
|
||||||
|
.editableCodeContainer {
|
||||||
|
position: relative;
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorControls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editingControls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton {
|
||||||
|
background: rgba(0, 122, 204, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton:hover {
|
||||||
|
background: rgba(0, 122, 204, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton {
|
||||||
|
background: rgba(40, 167, 69, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton:hover {
|
||||||
|
background: rgba(40, 167, 69, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
background: rgba(220, 53, 69, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton:hover {
|
||||||
|
background: rgba(220, 53, 69, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorWrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.syntaxHighlight {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorArea {
|
||||||
|
min-height: 150px;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorArea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
441
panel/styles/Form.module.css
Normal file
441
panel/styles/Form.module.css
Normal file
|
@ -0,0 +1,441 @@
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group select:disabled,
|
||||||
|
.form-group textarea:disabled {
|
||||||
|
background-color: var(--disabled-bg);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.5em 1.5em;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-horizontal label {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--error-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
color: var(--text-color-light);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-description {
|
||||||
|
color: var(--text-color-light);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option input[type="radio"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder для contenteditable div */
|
||||||
|
.input[contenteditable="true"]:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input[contenteditable="true"]:focus:empty::before {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для улучшенной формы редактирования пользователя */
|
||||||
|
.section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #e9ecef;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputError {
|
||||||
|
border-color: #dc3545;
|
||||||
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для грида ролей */
|
||||||
|
.rolesGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleCard {
|
||||||
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: #fff;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleCard:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleCardSelected {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleName {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleCheckmark {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleCardSelected .roleCheckmark {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleDescription {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Широкое модальное окно для переменных среды */
|
||||||
|
.modal-wide {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенные стили для форм */
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-disabled {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер для textarea с кнопками */
|
||||||
|
.textarea-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер для превью кода */
|
||||||
|
.code-preview-container {
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e1e;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview-container pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: transparent;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная справка */
|
||||||
|
.form-help {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0c4a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help strong {
|
||||||
|
color: #075985;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ошибки */
|
||||||
|
.form-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Действия формы */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для модального окна */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-wide {
|
||||||
|
max-width: 95vw;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
}
|
101
panel/styles/GlobalVariables.module.css
Normal file
101
panel/styles/GlobalVariables.module.css
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
/* Global CSS Variables */
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--primary-color-light: #dbeafe;
|
||||||
|
--primary-color-dark: #1e40af;
|
||||||
|
|
||||||
|
--secondary-color: #4b5563;
|
||||||
|
--secondary-color-light: #f3f4f6;
|
||||||
|
--secondary-color-dark: #1f2937;
|
||||||
|
|
||||||
|
--success-color: #059669;
|
||||||
|
--success-color-light: #d1fae5;
|
||||||
|
--success-color-dark: #065f46;
|
||||||
|
|
||||||
|
--warning-color: #d97706;
|
||||||
|
--warning-color-light: #fef3c7;
|
||||||
|
--warning-color-dark: #92400e;
|
||||||
|
|
||||||
|
--error-color: #dc2626;
|
||||||
|
--error-color-light: #fee2e2;
|
||||||
|
--error-color-dark: #991b1b;
|
||||||
|
|
||||||
|
--info-color: #0284c7;
|
||||||
|
--info-color-light: #e0f2fe;
|
||||||
|
--info-color-dark: #075985;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--text-color: #111827;
|
||||||
|
--text-color-light: #6b7280;
|
||||||
|
--text-color-lighter: #9ca3af;
|
||||||
|
|
||||||
|
/* Background Colors */
|
||||||
|
--background-color: #ffffff;
|
||||||
|
--header-background: #f9fafb;
|
||||||
|
--hover-color: #f3f4f6;
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--border-radius-sm: 0.25rem;
|
||||||
|
--border-radius-md: 0.375rem;
|
||||||
|
--border-radius-lg: 0.5rem;
|
||||||
|
|
||||||
|
/* Box Shadow */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.875rem;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--font-size-lg: 1.125rem;
|
||||||
|
--font-size-xl: 1.25rem;
|
||||||
|
--font-size-2xl: 1.5rem;
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
|
/* Line Heights */
|
||||||
|
--line-height-tight: 1.25;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms;
|
||||||
|
--transition-normal: 200ms;
|
||||||
|
--transition-slow: 300ms;
|
||||||
|
|
||||||
|
/* Z-Index */
|
||||||
|
--z-index-dropdown: 1000;
|
||||||
|
--z-index-sticky: 1020;
|
||||||
|
--z-index-fixed: 1030;
|
||||||
|
--z-index-modal-backdrop: 1040;
|
||||||
|
--z-index-modal: 1050;
|
||||||
|
--z-index-popover: 1060;
|
||||||
|
--z-index-tooltip: 1070;
|
||||||
|
|
||||||
|
/* Dark Mode Colors */
|
||||||
|
--dark-bg-color: #1f2937;
|
||||||
|
--dark-bg-color-dark: #111827;
|
||||||
|
--dark-hover-bg: #374151;
|
||||||
|
--dark-disabled-bg: #4b5563;
|
||||||
|
--dark-text-color: #f9fafb;
|
||||||
|
--dark-text-color-light: #d1d5db;
|
||||||
|
--dark-text-color-lighter: #9ca3af;
|
||||||
|
--dark-border-color: #374151;
|
||||||
|
--dark-border-color-dark: #4b5563;
|
||||||
|
}
|
0
panel/styles/Loading.module.css
Normal file
0
panel/styles/Loading.module.css
Normal file
78
panel/styles/Login.module.css
Normal file
78
panel/styles/Login.module.css
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form h1 {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: var(--secondary-color-light);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--error-color-light);
|
||||||
|
color: var(--error-color-dark);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-form {
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form h1 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
228
panel/styles/Modal.module.css
Normal file
228
panel/styles/Modal.module.css
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
width: 100%;
|
||||||
|
animation: modal-appear 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Sizes */
|
||||||
|
.modal-small {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-medium {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 95vw;
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large .content {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
color: var(--text-color-light);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-appear {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.backdrop {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-small,
|
||||||
|
.modal-medium,
|
||||||
|
.modal-large {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для больших модальных окон */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-large {
|
||||||
|
width: 95vw;
|
||||||
|
max-width: none;
|
||||||
|
margin: 20px;
|
||||||
|
min-height: auto;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large .content {
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role Modal Specific Styles */
|
||||||
|
.roles-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-option:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Environment Variable Modal Specific Styles */
|
||||||
|
.env-variable-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background-color: var(--disabled-bg);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body Preview Modal Specific Styles */
|
||||||
|
.body-preview {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: calc(90vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
114
panel/styles/Pagination.module.css
Normal file
114
panel/styles/Pagination.module.css
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
color: var(--text-color-light);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-ellipsis {
|
||||||
|
color: var(--text-color-light);
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-per-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageButton {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageButton:hover:not(:disabled) {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageButton:disabled {
|
||||||
|
background-color: var(--secondary-color-light);
|
||||||
|
color: var(--text-color-light);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentPage {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentPage:hover {
|
||||||
|
background-color: var(--primary-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perPageSelect {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perPageSelect:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perPageSelect:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pagination {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageButton {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-per-page {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
209
panel/styles/Table.module.css
Normal file
209
panel/styles/Table.module.css
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
.table-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: var(--bg-color-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
background-color: var(--primary-color-light);
|
||||||
|
color: var(--primary-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-badge {
|
||||||
|
background-color: var(--success-color-light);
|
||||||
|
color: var(--success-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-badge {
|
||||||
|
background-color: var(--info-color-light);
|
||||||
|
color: var(--info-color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading {
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin: -1rem;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: table-loading 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes table-loading {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Базовые стили для таблицы и контейнера */
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:nth-child(even) {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для действий */
|
||||||
|
.action-button {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для предупреждающих сообщений */
|
||||||
|
.warning-text {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для модальных действий */
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-row:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:hover {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
72
panel/styles/Utilities.module.css
Normal file
72
panel/styles/Utilities.module.css
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/* Utility classes for consistent styling */
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flexCol {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemsCenter {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justifyCenter {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justifyBetween {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap1 { gap: 4px; }
|
||||||
|
.gap2 { gap: 8px; }
|
||||||
|
.gap3 { gap: 12px; }
|
||||||
|
.gap4 { gap: 16px; }
|
||||||
|
.gap5 { gap: 20px; }
|
||||||
|
|
||||||
|
.m0 { margin: 0; }
|
||||||
|
.mt1 { margin-top: 4px; }
|
||||||
|
.mt2 { margin-top: 8px; }
|
||||||
|
.mt3 { margin-top: 12px; }
|
||||||
|
.mt4 { margin-top: 16px; }
|
||||||
|
.mt5 { margin-top: 20px; }
|
||||||
|
|
||||||
|
.mb1 { margin-bottom: 4px; }
|
||||||
|
.mb2 { margin-bottom: 8px; }
|
||||||
|
.mb3 { margin-bottom: 12px; }
|
||||||
|
.mb4 { margin-bottom: 16px; }
|
||||||
|
.mb5 { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.p0 { padding: 0; }
|
||||||
|
.p1 { padding: 4px; }
|
||||||
|
.p2 { padding: 8px; }
|
||||||
|
.p3 { padding: 12px; }
|
||||||
|
.p4 { padding: 16px; }
|
||||||
|
.p5 { padding: 20px; }
|
||||||
|
|
||||||
|
.textXs { font-size: 12px; }
|
||||||
|
.textSm { font-size: 14px; }
|
||||||
|
.textBase { font-size: 16px; }
|
||||||
|
.textLg { font-size: 18px; }
|
||||||
|
.textXl { font-size: 20px; }
|
||||||
|
.text2Xl { font-size: 24px; }
|
||||||
|
|
||||||
|
.fontNormal { font-weight: 400; }
|
||||||
|
.fontMedium { font-weight: 500; }
|
||||||
|
.fontSemibold { font-weight: 600; }
|
||||||
|
.fontBold { font-weight: 700; }
|
||||||
|
|
||||||
|
.textPrimary { color: var(--primary-color); }
|
||||||
|
.textSecondary { color: var(--text-secondary); }
|
||||||
|
.textMuted { color: var(--text-muted); }
|
||||||
|
.textSuccess { color: var(--success-color); }
|
||||||
|
.textDanger { color: var(--danger-color); }
|
||||||
|
.textWarning { color: var(--warning-color); }
|
||||||
|
|
||||||
|
.bgWhite { background-color: var(--bg-color); }
|
||||||
|
.bgCard { background-color: var(--card-bg); }
|
||||||
|
.bgSuccessLight { background-color: var(--success-light); }
|
||||||
|
.bgDangerLight { background-color: var(--danger-light); }
|
||||||
|
.bgWarningLight { background-color: var(--warning-light); }
|
4
panel/types/css.d.ts
vendored
Normal file
4
panel/types/css.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const styles: { [key: string]: string }
|
||||||
|
export default styles
|
||||||
|
}
|
15
panel/types/svg.d.ts
vendored
Normal file
15
panel/types/svg.d.ts
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
declare module '*.svg' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg?component' {
|
||||||
|
import type { Component } from 'solid-js'
|
||||||
|
const component: Component
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg?url' {
|
||||||
|
const url: string
|
||||||
|
export default url
|
||||||
|
}
|
35
panel/ui/Button.tsx
Normal file
35
panel/ui/Button.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { Component, JSX, splitProps } from 'solid-js'
|
||||||
|
import styles from '../styles/Button.module.css'
|
||||||
|
|
||||||
|
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger'
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
loading?: boolean
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: Component<ButtonProps> = (props) => {
|
||||||
|
const [local, rest] = splitProps(props, ['variant', 'size', 'loading', 'fullWidth', 'class', 'children'])
|
||||||
|
|
||||||
|
const classes = () => {
|
||||||
|
const baseClass = styles.button
|
||||||
|
const variantClass = styles[`button-${local.variant || 'primary'}`]
|
||||||
|
const sizeClass = styles[`button-${local.size || 'medium'}`]
|
||||||
|
const loadingClass = local.loading ? styles['button-loading'] : ''
|
||||||
|
const fullWidthClass = local.fullWidth ? styles['button-full-width'] : ''
|
||||||
|
const customClass = local.class || ''
|
||||||
|
|
||||||
|
return [baseClass, variantClass, sizeClass, loadingClass, fullWidthClass, customClass]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button {...rest} class={classes()} disabled={props.disabled || local.loading}>
|
||||||
|
{local.loading && <span class={styles['loading-spinner']} />}
|
||||||
|
{local.children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Button
|
103
panel/ui/CodePreview.tsx
Normal file
103
panel/ui/CodePreview.tsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import Prism from 'prismjs'
|
||||||
|
import { JSX } from 'solid-js'
|
||||||
|
import 'prismjs/components/prism-json'
|
||||||
|
import 'prismjs/components/prism-markup'
|
||||||
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
|
|
||||||
|
import styles from '../styles/CodePreview.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет язык контента (html или json)
|
||||||
|
*/
|
||||||
|
function detectLanguage(content: string): string {
|
||||||
|
try {
|
||||||
|
JSON.parse(content)
|
||||||
|
return 'json'
|
||||||
|
} catch {
|
||||||
|
if (/<[^>]*>/g.test(content)) {
|
||||||
|
return 'markup'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'plaintext'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует XML/HTML с отступами
|
||||||
|
*/
|
||||||
|
function prettyFormatXML(xml: string): string {
|
||||||
|
let formatted = ''
|
||||||
|
const reg = /(>)(<)(\/*)/g
|
||||||
|
const res = xml.replace(reg, '$1\r\n$2$3')
|
||||||
|
let pad = 0
|
||||||
|
res.split('\r\n').forEach((node) => {
|
||||||
|
let indent = 0
|
||||||
|
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||||||
|
indent = 0
|
||||||
|
} else if (node.match(/^<\//)) {
|
||||||
|
if (pad !== 0) pad -= 2
|
||||||
|
} else if (node.match(/^<\w([^>]*[^/])?>.*$/)) {
|
||||||
|
indent = 2
|
||||||
|
} else {
|
||||||
|
indent = 0
|
||||||
|
}
|
||||||
|
formatted += `${' '.repeat(pad)}${node}\r\n`
|
||||||
|
pad += indent
|
||||||
|
})
|
||||||
|
return formatted.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует и подсвечивает код
|
||||||
|
*/
|
||||||
|
function formatCode(content: string): string {
|
||||||
|
const language = detectLanguage(content)
|
||||||
|
|
||||||
|
if (language === 'json') {
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(JSON.parse(content), null, 2)
|
||||||
|
return Prism.highlight(formatted, Prism.languages[language], language)
|
||||||
|
} catch {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
} else if (language === 'markup') {
|
||||||
|
const formatted = prettyFormatXML(content)
|
||||||
|
return Prism.highlight(formatted, Prism.languages[language], language)
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodePreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
|
||||||
|
content: string
|
||||||
|
language?: string
|
||||||
|
maxHeight?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodePreview = (props: CodePreviewProps) => {
|
||||||
|
const language = () => props.language || detectLanguage(props.content)
|
||||||
|
// const formattedCode = () => formatCode(props.content)
|
||||||
|
|
||||||
|
const numberedCode = () => {
|
||||||
|
const lines = props.content.split('\n')
|
||||||
|
return lines
|
||||||
|
.map((line, index) => `<span class="${styles.lineNumber}">${index + 1}</span>${line}`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
{...props}
|
||||||
|
class={`${styles.codePreview} ${props.class || ''}`}
|
||||||
|
style={`max-height: ${props.maxHeight || '500px'}; overflow-y: auto; ${props.style || ''}`}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
class={`language-${language()} ${styles.code}`}
|
||||||
|
innerHTML={Prism.highlight(numberedCode(), Prism.languages[language()], language())}
|
||||||
|
/>
|
||||||
|
{props.language && <span class={styles.languageBadge}>{props.language}</span>}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodePreview
|
||||||
|
export { detectLanguage, formatCode }
|
266
panel/ui/EditableCodePreview.tsx
Normal file
266
panel/ui/EditableCodePreview.tsx
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
import Prism from 'prismjs'
|
||||||
|
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||||
|
import 'prismjs/components/prism-json'
|
||||||
|
import 'prismjs/components/prism-markup'
|
||||||
|
import 'prismjs/components/prism-javascript'
|
||||||
|
import 'prismjs/components/prism-css'
|
||||||
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
|
|
||||||
|
import styles from '../styles/CodePreview.module.css'
|
||||||
|
import { detectLanguage } from './CodePreview'
|
||||||
|
|
||||||
|
interface EditableCodePreviewProps {
|
||||||
|
content: string
|
||||||
|
onContentChange: (newContent: string) => void
|
||||||
|
onSave?: (content: string) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
language?: string
|
||||||
|
maxHeight?: string
|
||||||
|
placeholder?: string
|
||||||
|
showButtons?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Редактируемый компонент для кода с подсветкой синтаксиса
|
||||||
|
*/
|
||||||
|
const EditableCodePreview = (props: EditableCodePreviewProps) => {
|
||||||
|
const [isEditing, setIsEditing] = createSignal(false)
|
||||||
|
const [content, setContent] = createSignal(props.content)
|
||||||
|
let editorRef: HTMLDivElement | undefined
|
||||||
|
let highlightRef: HTMLPreElement | undefined
|
||||||
|
|
||||||
|
const language = () => props.language || detectLanguage(content())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет подсветку синтаксиса
|
||||||
|
*/
|
||||||
|
const updateHighlight = () => {
|
||||||
|
if (!highlightRef) return
|
||||||
|
|
||||||
|
const code = content() || ''
|
||||||
|
const lang = language()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Prism.languages[lang]) {
|
||||||
|
highlightRef.innerHTML = Prism.highlight(code, Prism.languages[lang], lang)
|
||||||
|
} else {
|
||||||
|
highlightRef.textContent = code
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error highlighting code:', e)
|
||||||
|
highlightRef.textContent = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизирует скролл между редактором и подсветкой
|
||||||
|
*/
|
||||||
|
const syncScroll = () => {
|
||||||
|
if (editorRef && highlightRef) {
|
||||||
|
highlightRef.scrollTop = editorRef.scrollTop
|
||||||
|
highlightRef.scrollLeft = editorRef.scrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик изменения контента
|
||||||
|
*/
|
||||||
|
const handleInput = (e: Event) => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
const newContent = target.textContent || ''
|
||||||
|
setContent(newContent)
|
||||||
|
props.onContentChange(newContent)
|
||||||
|
updateHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик сохранения
|
||||||
|
*/
|
||||||
|
const handleSave = () => {
|
||||||
|
if (props.onSave) {
|
||||||
|
props.onSave(content())
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик отмены
|
||||||
|
*/
|
||||||
|
const handleCancel = () => {
|
||||||
|
setContent(props.content) // Возвращаем исходный контент
|
||||||
|
if (props.onCancel) {
|
||||||
|
props.onCancel()
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик клавиш
|
||||||
|
*/
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+Enter или Cmd+Enter для сохранения
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape для отмены
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab для отступа
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
// const target = e.target as HTMLDivElement
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
range.deleteContents()
|
||||||
|
range.insertNode(document.createTextNode(' ')) // Два пробела
|
||||||
|
range.collapse(false)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
handleInput(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Эффект для обновления контента при изменении props
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isEditing()) {
|
||||||
|
setContent(props.content)
|
||||||
|
updateHighlight()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Эффект для обновления подсветки при изменении контента
|
||||||
|
createEffect(() => {
|
||||||
|
content() // Реактивность
|
||||||
|
updateHighlight()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateHighlight()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.editableCodeContainer}>
|
||||||
|
{/* Кнопки управления */}
|
||||||
|
{props.showButtons !== false && (
|
||||||
|
<div class={styles.editorControls}>
|
||||||
|
{!isEditing() ? (
|
||||||
|
<button class={styles.editButton} onClick={() => setIsEditing(true)}>
|
||||||
|
✏️ Редактировать
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div class={styles.editingControls}>
|
||||||
|
<button class={styles.saveButton} onClick={handleSave}>
|
||||||
|
💾 Сохранить (Ctrl+Enter)
|
||||||
|
</button>
|
||||||
|
<button class={styles.cancelButton} onClick={handleCancel}>
|
||||||
|
❌ Отмена (Esc)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Контейнер редактора */}
|
||||||
|
<div
|
||||||
|
class={styles.editorWrapper}
|
||||||
|
style={`max-height: ${props.maxHeight || '70vh'}; ${isEditing() ? 'border: 2px solid #007acc;' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Подсветка синтаксиса (фон) */}
|
||||||
|
<pre
|
||||||
|
ref={highlightRef}
|
||||||
|
class={`${styles.syntaxHighlight} language-${language()}`}
|
||||||
|
style="position: absolute; top: 0; left: 0; pointer-events: none; color: transparent; background: transparent; margin: 0; padding: 12px; font-family: 'Fira Code', monospace; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; overflow: hidden;"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Редактируемая область */}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable={isEditing()}
|
||||||
|
class={styles.editorArea}
|
||||||
|
style={`
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: ${isEditing() ? 'rgba(0, 0, 0, 0.05)' : 'transparent'};
|
||||||
|
color: ${isEditing() ? 'rgba(255, 255, 255, 0.9)' : 'transparent'};
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-y: auto;
|
||||||
|
outline: none;
|
||||||
|
cursor: ${isEditing() ? 'text' : 'default'};
|
||||||
|
caret-color: ${isEditing() ? '#fff' : 'transparent'};
|
||||||
|
`}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onScroll={syncScroll}
|
||||||
|
spellcheck={false}
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Превью для неактивного режима */}
|
||||||
|
{!isEditing() && (
|
||||||
|
<pre
|
||||||
|
class={`${styles.codePreview} language-${language()}`}
|
||||||
|
style={`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
`}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
class={`language-${language()}`}
|
||||||
|
innerHTML={(() => {
|
||||||
|
try {
|
||||||
|
return Prism.highlight(content(), Prism.languages[language()], language())
|
||||||
|
} catch {
|
||||||
|
return content()
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Плейсхолдер */}
|
||||||
|
{!content() && (
|
||||||
|
<div
|
||||||
|
class={styles.placeholder}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; cursor: pointer; font-style: italic;"
|
||||||
|
>
|
||||||
|
{props.placeholder || 'Нажмите для редактирования...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикатор языка */}
|
||||||
|
<span class={styles.languageBadge}>{language()}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditableCodePreview
|
48
panel/ui/Modal.tsx
Normal file
48
panel/ui/Modal.tsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { Component, JSX, Show } from 'solid-js'
|
||||||
|
import styles from '../styles/Modal.module.css'
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
title: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
children: JSX.Element
|
||||||
|
footer?: JSX.Element
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modal: Component<ModalProps> = (props) => {
|
||||||
|
const handleBackdropClick = (e: MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalClasses = () => {
|
||||||
|
const baseClass = styles.modal
|
||||||
|
const sizeClass = styles[`modal-${props.size || 'medium'}`]
|
||||||
|
return [baseClass, sizeClass].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.isOpen}>
|
||||||
|
<div class={styles.backdrop} onClick={handleBackdropClick}>
|
||||||
|
<div class={modalClasses()}>
|
||||||
|
<div class={styles.header}>
|
||||||
|
<h2 class={styles.title}>{props.title}</h2>
|
||||||
|
<button class={styles.close} onClick={props.onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.content}>{props.children}</div>
|
||||||
|
|
||||||
|
<Show when={props.footer}>
|
||||||
|
<div class={styles.footer}>{props.footer}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Modal
|
117
panel/ui/Pagination.tsx
Normal file
117
panel/ui/Pagination.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import { For } from 'solid-js'
|
||||||
|
import styles from '../styles/Pagination.module.css'
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
onPerPageChange?: (limit: number) => void
|
||||||
|
perPageOptions?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const Pagination = (props: PaginationProps) => {
|
||||||
|
const perPageOptions = props.perPageOptions || [10, 20, 50, 100]
|
||||||
|
|
||||||
|
// Генерируем массив страниц для отображения
|
||||||
|
const pages = () => {
|
||||||
|
const result: (number | string)[] = []
|
||||||
|
const maxVisiblePages = 5 // Максимальное количество видимых страниц
|
||||||
|
|
||||||
|
// Всегда показываем первую страницу
|
||||||
|
result.push(1)
|
||||||
|
|
||||||
|
// Вычисляем диапазон страниц вокруг текущей
|
||||||
|
let startPage = Math.max(2, props.currentPage - Math.floor(maxVisiblePages / 2))
|
||||||
|
const endPage = Math.min(props.totalPages - 1, startPage + maxVisiblePages - 2)
|
||||||
|
|
||||||
|
// Корректируем диапазон, если он выходит за границы
|
||||||
|
if (endPage - startPage < maxVisiblePages - 2) {
|
||||||
|
startPage = Math.max(2, endPage - maxVisiblePages + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем многоточие после первой страницы, если нужно
|
||||||
|
if (startPage > 2) {
|
||||||
|
result.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем страницы из диапазона
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
result.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем многоточие перед последней страницей, если нужно
|
||||||
|
if (endPage < props.totalPages - 1) {
|
||||||
|
result.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда показываем последнюю страницу, если есть больше одной страницы
|
||||||
|
if (props.totalPages > 1) {
|
||||||
|
result.push(props.totalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = () => (props.currentPage - 1) * props.limit + 1
|
||||||
|
const endIndex = () => Math.min(props.currentPage * props.limit, props.total)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.pagination}>
|
||||||
|
<div class={styles['pagination-info']}>
|
||||||
|
Показано {startIndex()} - {endIndex()} из {props.total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles['pagination-controls']}>
|
||||||
|
<button
|
||||||
|
class={styles.pageButton}
|
||||||
|
onClick={() => props.onPageChange(props.currentPage - 1)}
|
||||||
|
disabled={props.currentPage === 1}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<For each={pages()}>
|
||||||
|
{(page) => (
|
||||||
|
<>
|
||||||
|
{page === '...' ? (
|
||||||
|
<span class={styles['pagination-ellipsis']}>...</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
class={`${styles.pageButton} ${page === props.currentPage ? styles.currentPage : ''}`}
|
||||||
|
onClick={() => props.onPageChange(Number(page))}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={styles.pageButton}
|
||||||
|
onClick={() => props.onPageChange(props.currentPage + 1)}
|
||||||
|
disabled={props.currentPage === props.totalPages}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.onPerPageChange && (
|
||||||
|
<div class={styles['pagination-per-page']}>
|
||||||
|
На странице:
|
||||||
|
<select
|
||||||
|
class={styles.perPageSelect}
|
||||||
|
value={props.limit}
|
||||||
|
onChange={(e) => props.onPerPageChange!(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<For each={perPageOptions}>{(option) => <option value={option}>{option}</option>}</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination
|
64
panel/ui/TextPreview.tsx
Normal file
64
panel/ui/TextPreview.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { JSX } from 'solid-js'
|
||||||
|
import styles from '../styles/CodePreview.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент для простого просмотра текста без подсветки syntax
|
||||||
|
* Убирает HTML теги и показывает чистый текст
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TextPreviewProps extends JSX.HTMLAttributes<HTMLPreElement> {
|
||||||
|
content: string
|
||||||
|
maxHeight?: string
|
||||||
|
showLineNumbers?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает HTML теги и декодирует HTML entity
|
||||||
|
*/
|
||||||
|
function stripHtmlTags(text: string): string {
|
||||||
|
// Убираем HTML теги
|
||||||
|
let cleaned = text.replace(/<[^>]*>/g, '')
|
||||||
|
|
||||||
|
// Декодируем базовые HTML entity
|
||||||
|
cleaned = cleaned
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
|
||||||
|
return cleaned.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextPreview = (props: TextPreviewProps) => {
|
||||||
|
const cleanedContent = () => stripHtmlTags(props.content)
|
||||||
|
|
||||||
|
const contentWithLines = () => {
|
||||||
|
if (!props.showLineNumbers) return cleanedContent()
|
||||||
|
|
||||||
|
const lines = cleanedContent().split('\n')
|
||||||
|
return lines.map((line, index) => `${(index + 1).toString().padStart(3, ' ')} | ${line}`).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
{...props}
|
||||||
|
class={`${styles.codePreview} ${props.class || ''}`}
|
||||||
|
style={`
|
||||||
|
max-height: ${props.maxHeight || '60vh'};
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
${props.style || ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<code class={styles.code}>{contentWithLines()}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextPreview
|
99
panel/utils/auth.ts
Normal file
99
panel/utils/auth.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* Утилиты для работы с токенами авторизации
|
||||||
|
* @module auth-utils
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Экспортируем константы для использования в других модулях
|
||||||
|
export const AUTH_TOKEN_KEY = 'auth_token'
|
||||||
|
export const CSRF_TOKEN_KEY = 'csrf_token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает токен авторизации из cookie
|
||||||
|
* @returns Токен или пустую строку, если токен не найден
|
||||||
|
*/
|
||||||
|
export function getAuthTokenFromCookie(): string {
|
||||||
|
console.log('[Auth] Checking auth token in cookies...')
|
||||||
|
const cookieItems = document.cookie.split(';')
|
||||||
|
for (const item of cookieItems) {
|
||||||
|
const [name, value] = item.trim().split('=')
|
||||||
|
if (name === AUTH_TOKEN_KEY) {
|
||||||
|
console.log('[Auth] Found auth token in cookies')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[Auth] No auth token found in cookies')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает CSRF-токен из cookie
|
||||||
|
* @returns CSRF-токен или пустую строку, если токен не найден
|
||||||
|
*/
|
||||||
|
export function getCsrfTokenFromCookie(): string {
|
||||||
|
console.log('[Auth] Checking CSRF token in cookies...')
|
||||||
|
const cookieItems = document.cookie.split(';')
|
||||||
|
for (const item of cookieItems) {
|
||||||
|
const [name, value] = item.trim().split('=')
|
||||||
|
if (name === CSRF_TOKEN_KEY) {
|
||||||
|
console.log('[Auth] Found CSRF token in cookies')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[Auth] No CSRF token found in cookies')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очищает все токены авторизации
|
||||||
|
*/
|
||||||
|
export function clearAuthTokens(): void {
|
||||||
|
console.log('[Auth] Clearing all auth tokens...')
|
||||||
|
// Очищаем токен из localStorage
|
||||||
|
localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||||
|
|
||||||
|
// Для удаления cookie устанавливаем ей истекшее время жизни
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: Требуется для кроссбраузерной совместимости
|
||||||
|
document.cookie = `${AUTH_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: Требуется для кроссбраузерной совместимости
|
||||||
|
document.cookie = `${CSRF_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
|
||||||
|
console.log('[Auth] Auth tokens cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет токен авторизации
|
||||||
|
* @param token - Токен для сохранения
|
||||||
|
*/
|
||||||
|
export function saveAuthToken(token: string): void {
|
||||||
|
console.log('[Auth] Attempting to save auth token...')
|
||||||
|
if (!token) {
|
||||||
|
console.log('[Auth] No token provided, skipping save')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Всегда сохраняем токен в localStorage для надежности
|
||||||
|
localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||||
|
console.log('[Auth] Token saved to localStorage')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, авторизован ли пользователь
|
||||||
|
* @returns Статус авторизации
|
||||||
|
*/
|
||||||
|
export function checkAuthStatus(): boolean {
|
||||||
|
console.log('[Auth] Checking authentication status...')
|
||||||
|
|
||||||
|
// Проверяем наличие cookie auth_token
|
||||||
|
const cookieToken = getAuthTokenFromCookie()
|
||||||
|
const hasCookie = !!cookieToken && cookieToken.length > 10
|
||||||
|
|
||||||
|
// Проверяем наличие токена в localStorage
|
||||||
|
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
|
||||||
|
const hasLocalToken = !!localToken && localToken.length > 10
|
||||||
|
|
||||||
|
const isAuth = hasCookie || hasLocalToken
|
||||||
|
console.log(`[Auth] Cookie token: ${hasCookie ? 'present' : 'missing'}`)
|
||||||
|
console.log(`[Auth] Local token: ${hasLocalToken ? 'present' : 'missing'}`)
|
||||||
|
console.log(`[Auth] Authentication status: ${isAuth ? 'authenticated' : 'not authenticated'}`)
|
||||||
|
|
||||||
|
return isAuth
|
||||||
|
}
|
104
panel/utils/date.ts
Normal file
104
panel/utils/date.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* Форматирование даты в формате "X дней назад"
|
||||||
|
* @param timestamp - Временная метка
|
||||||
|
* @returns Форматированная строка с относительной датой
|
||||||
|
*/
|
||||||
|
export function formatDateRelative(timestamp?: number): string {
|
||||||
|
if (!timestamp) return 'Н/Д'
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const diff = now - timestamp
|
||||||
|
|
||||||
|
// Меньше минуты
|
||||||
|
if (diff < 60) {
|
||||||
|
return 'только что'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше часа
|
||||||
|
if (diff < 3600) {
|
||||||
|
const minutes = Math.floor(diff / 60)
|
||||||
|
return `${minutes} ${getMinutesForm(minutes)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше суток
|
||||||
|
if (diff < 86400) {
|
||||||
|
const hours = Math.floor(diff / 3600)
|
||||||
|
return `${hours} ${getHoursForm(hours)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше 30 дней
|
||||||
|
if (diff < 2592000) {
|
||||||
|
const days = Math.floor(diff / 86400)
|
||||||
|
return `${days} ${getDaysForm(days)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меньше года
|
||||||
|
if (diff < 31536000) {
|
||||||
|
const months = Math.floor(diff / 2592000)
|
||||||
|
return `${months} ${getMonthsForm(months)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Больше года
|
||||||
|
const years = Math.floor(diff / 31536000)
|
||||||
|
return `${years} ${getYearsForm(years)} назад`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "минута" в зависимости от числа
|
||||||
|
*/
|
||||||
|
function getMinutesForm(minutes: number): string {
|
||||||
|
if (minutes % 10 === 1 && minutes % 100 !== 11) {
|
||||||
|
return 'минуту'
|
||||||
|
} else if ([2, 3, 4].includes(minutes % 10) && ![12, 13, 14].includes(minutes % 100)) {
|
||||||
|
return 'минуты'
|
||||||
|
}
|
||||||
|
return 'минут'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "час" в зависимости от числа
|
||||||
|
*/
|
||||||
|
function getHoursForm(hours: number): string {
|
||||||
|
if (hours % 10 === 1 && hours % 100 !== 11) {
|
||||||
|
return 'час'
|
||||||
|
} else if ([2, 3, 4].includes(hours % 10) && ![12, 13, 14].includes(hours % 100)) {
|
||||||
|
return 'часа'
|
||||||
|
}
|
||||||
|
return 'часов'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "день" в зависимости от числа
|
||||||
|
*/
|
||||||
|
function getDaysForm(days: number): string {
|
||||||
|
if (days % 10 === 1 && days % 100 !== 11) {
|
||||||
|
return 'день'
|
||||||
|
} else if ([2, 3, 4].includes(days % 10) && ![12, 13, 14].includes(days % 100)) {
|
||||||
|
return 'дня'
|
||||||
|
}
|
||||||
|
return 'дней'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "месяц" в зависимости от числа
|
||||||
|
*/
|
||||||
|
function getMonthsForm(months: number): string {
|
||||||
|
if (months % 10 === 1 && months % 100 !== 11) {
|
||||||
|
return 'месяц'
|
||||||
|
} else if ([2, 3, 4].includes(months % 10) && ![12, 13, 14].includes(months % 100)) {
|
||||||
|
return 'месяца'
|
||||||
|
}
|
||||||
|
return 'месяцев'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение правильной формы слова "год" в зависимости от числа
|
||||||
|
*/
|
||||||
|
function getYearsForm(years: number): string {
|
||||||
|
if (years % 10 === 1 && years % 100 !== 11) {
|
||||||
|
return 'год'
|
||||||
|
} else if ([2, 3, 4].includes(years % 10) && ![12, 13, 14].includes(years % 100)) {
|
||||||
|
return 'года'
|
||||||
|
}
|
||||||
|
return 'лет'
|
||||||
|
}
|
|
@ -62,11 +62,11 @@ async def admin_get_users(
|
||||||
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
||||||
|
|
||||||
# Применяем пагинацию
|
# Применяем пагинацию
|
||||||
users = query.order_by(Author.id).offset(offset).limit(limit).all()
|
authors = query.order_by(Author.id).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
# Преобразуем в формат для API
|
# Преобразуем в формат для API
|
||||||
return {
|
return {
|
||||||
"users": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
@ -76,7 +76,7 @@ async def admin_get_users(
|
||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
"last_seen": user.last_seen,
|
"last_seen": user.last_seen,
|
||||||
}
|
}
|
||||||
for user in users
|
for user in authors
|
||||||
],
|
],
|
||||||
"total": total_count,
|
"total": total_count,
|
||||||
"page": current_page,
|
"page": current_page,
|
||||||
|
@ -247,11 +247,11 @@ async def update_env_variables(_: None, info: GraphQLResolveInfo, variables: lis
|
||||||
@admin_auth_required
|
@admin_auth_required
|
||||||
async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, Any]) -> dict[str, Any]:
|
async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Обновляет роли пользователя
|
Обновляет данные пользователя (роли, email, имя, slug)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
info: Контекст GraphQL запроса
|
info: Контекст GraphQL запроса
|
||||||
user: Данные для обновления пользователя (содержит id и roles)
|
user: Данные для обновления пользователя
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Boolean: результат операции или объект с ошибкой
|
Boolean: результат операции или объект с ошибкой
|
||||||
|
@ -259,6 +259,9 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
|
||||||
try:
|
try:
|
||||||
user_id = user.get("id")
|
user_id = user.get("id")
|
||||||
roles = user.get("roles", [])
|
roles = user.get("roles", [])
|
||||||
|
email = user.get("email")
|
||||||
|
name = user.get("name")
|
||||||
|
slug = user.get("slug")
|
||||||
|
|
||||||
if not roles:
|
if not roles:
|
||||||
logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
|
logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
|
||||||
|
@ -272,6 +275,28 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"success": False, "error": error_msg}
|
return {"success": False, "error": error_msg}
|
||||||
|
|
||||||
|
# Обновляем основные поля профиля
|
||||||
|
profile_updated = False
|
||||||
|
if email is not None and email != author.email:
|
||||||
|
# Проверяем уникальность email
|
||||||
|
existing_author = session.query(Author).filter(Author.email == email, Author.id != user_id).first()
|
||||||
|
if existing_author:
|
||||||
|
return {"success": False, "error": f"Email {email} уже используется другим пользователем"}
|
||||||
|
author.email = email
|
||||||
|
profile_updated = True
|
||||||
|
|
||||||
|
if name is not None and name != author.name:
|
||||||
|
author.name = name
|
||||||
|
profile_updated = True
|
||||||
|
|
||||||
|
if slug is not None and slug != author.slug:
|
||||||
|
# Проверяем уникальность slug
|
||||||
|
existing_author = session.query(Author).filter(Author.slug == slug, Author.id != user_id).first()
|
||||||
|
if existing_author:
|
||||||
|
return {"success": False, "error": f"Slug {slug} уже используется другим пользователем"}
|
||||||
|
author.slug = slug
|
||||||
|
profile_updated = True
|
||||||
|
|
||||||
# Получаем ID сообщества по умолчанию
|
# Получаем ID сообщества по умолчанию
|
||||||
default_community_id = 1 # Используем значение по умолчанию из модели AuthorRole
|
default_community_id = 1 # Используем значение по умолчанию из модели AuthorRole
|
||||||
|
|
||||||
|
@ -307,19 +332,25 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
|
||||||
f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен."
|
f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен."
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Роли пользователя {author.email or author.id} обновлены: {', '.join(found_role_ids)}")
|
update_details = []
|
||||||
|
if profile_updated:
|
||||||
|
update_details.append("профиль")
|
||||||
|
if roles:
|
||||||
|
update_details.append(f"роли: {', '.join(found_role_ids)}")
|
||||||
|
|
||||||
|
logger.info(f"Данные пользователя {author.email or author.id} обновлены: {', '.join(update_details)}")
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Обработка вложенных исключений
|
# Обработка вложенных исключений
|
||||||
session.rollback()
|
session.rollback()
|
||||||
error_msg = f"Ошибка при изменении ролей: {e!s}"
|
error_msg = f"Ошибка при изменении данных пользователя: {e!s}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"success": False, "error": error_msg}
|
return {"success": False, "error": error_msg}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
error_msg = f"Ошибка при обновлении ролей пользователя: {e!s}"
|
error_msg = f"Ошибка при обновлении данных пользователя: {e!s}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "error": error_msg}
|
return {"success": False, "error": error_msg}
|
||||||
|
|
|
@ -2,15 +2,49 @@ from typing import Any
|
||||||
|
|
||||||
from graphql import GraphQLResolveInfo
|
from graphql import GraphQLResolveInfo
|
||||||
|
|
||||||
|
from auth.decorators import editor_or_admin_required
|
||||||
from auth.orm import Author
|
from auth.orm import Author
|
||||||
from orm.community import Community, CommunityFollower
|
from orm.community import Community, CommunityFollower
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query, type_community
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_communities_all")
|
@query.field("get_communities_all")
|
||||||
async def get_communities_all(_: None, _info: GraphQLResolveInfo) -> list[Community]:
|
async def get_communities_all(_: None, _info: GraphQLResolveInfo) -> list[Community]:
|
||||||
return local_session().query(Community).all()
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
# Загружаем сообщества с проверкой существования авторов
|
||||||
|
communities = (
|
||||||
|
session.query(Community)
|
||||||
|
.options(joinedload(Community.created_by_author))
|
||||||
|
.join(
|
||||||
|
Author,
|
||||||
|
Community.created_by == Author.id, # INNER JOIN - исключает сообщества без авторов
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Community.created_by.isnot(None), # Дополнительная проверка
|
||||||
|
Author.id.isnot(None), # Проверяем что автор существует
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительная проверка валидности данных
|
||||||
|
valid_communities = []
|
||||||
|
for community in communities:
|
||||||
|
if (
|
||||||
|
community.created_by
|
||||||
|
and hasattr(community, "created_by_author")
|
||||||
|
and community.created_by_author
|
||||||
|
and community.created_by_author.id
|
||||||
|
):
|
||||||
|
valid_communities.append(community)
|
||||||
|
else:
|
||||||
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
logger.warning(f"Исключено сообщество {community.id} ({community.slug}) - проблемы с автором")
|
||||||
|
|
||||||
|
return valid_communities
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_community")
|
@query.field("get_community")
|
||||||
|
@ -63,41 +97,192 @@ async def leave_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("create_community")
|
@mutation.field("create_community")
|
||||||
async def create_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]:
|
@editor_or_admin_required
|
||||||
author_dict = info.context.get("author", {})
|
async def create_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
|
||||||
author_id = author_dict.get("id")
|
# Получаем author_id из контекста через декоратор авторизации
|
||||||
|
request = info.context.get("request")
|
||||||
|
author_id = None
|
||||||
|
|
||||||
|
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
|
||||||
|
author_id = request.auth.author_id
|
||||||
|
elif hasattr(request, "scope") and "auth" in request.scope:
|
||||||
|
auth_info = request.scope.get("auth", {})
|
||||||
|
if isinstance(auth_info, dict):
|
||||||
|
author_id = auth_info.get("author_id")
|
||||||
|
elif hasattr(auth_info, "author_id"):
|
||||||
|
author_id = auth_info.author_id
|
||||||
|
|
||||||
|
if not author_id:
|
||||||
|
return {"error": "Не удалось определить автора"}
|
||||||
|
|
||||||
|
try:
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
session.add(Community(author=author_id, **community_data))
|
# Исключаем created_by из входных данных - он всегда из токена
|
||||||
|
filtered_input = {k: v for k, v in community_input.items() if k != "created_by"}
|
||||||
|
|
||||||
|
# Создаем новое сообщество с обязательным created_by из токена
|
||||||
|
new_community = Community(created_by=author_id, **filtered_input)
|
||||||
|
session.add(new_community)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"error": None}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Ошибка создания сообщества: {e!s}"}
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("update_community")
|
@mutation.field("update_community")
|
||||||
async def update_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]:
|
@editor_or_admin_required
|
||||||
author_dict = info.context.get("author", {})
|
async def update_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
|
||||||
author_id = author_dict.get("id")
|
# Получаем author_id из контекста через декоратор авторизации
|
||||||
slug = community_data.get("slug")
|
request = info.context.get("request")
|
||||||
if slug:
|
author_id = None
|
||||||
with local_session() as session:
|
|
||||||
|
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
|
||||||
|
author_id = request.auth.author_id
|
||||||
|
elif hasattr(request, "scope") and "auth" in request.scope:
|
||||||
|
auth_info = request.scope.get("auth", {})
|
||||||
|
if isinstance(auth_info, dict):
|
||||||
|
author_id = auth_info.get("author_id")
|
||||||
|
elif hasattr(auth_info, "author_id"):
|
||||||
|
author_id = auth_info.author_id
|
||||||
|
|
||||||
|
if not author_id:
|
||||||
|
return {"error": "Не удалось определить автора"}
|
||||||
|
|
||||||
|
slug = community_input.get("slug")
|
||||||
|
if not slug:
|
||||||
|
return {"error": "Не указан slug сообщества"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update(
|
with local_session() as session:
|
||||||
community_data
|
# Находим сообщество для обновления
|
||||||
)
|
community = session.query(Community).filter(Community.slug == slug).first()
|
||||||
|
if not community:
|
||||||
|
return {"error": "Сообщество не найдено"}
|
||||||
|
|
||||||
|
# Проверяем права на редактирование (создатель или админ/редактор)
|
||||||
|
with local_session() as auth_session:
|
||||||
|
author = auth_session.query(Author).filter(Author.id == author_id).first()
|
||||||
|
user_roles = [role.id for role in author.roles] if author and author.roles else []
|
||||||
|
|
||||||
|
# Разрешаем редактирование если пользователь - создатель или имеет роль admin/editor
|
||||||
|
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
|
||||||
|
return {"error": "Недостаточно прав для редактирования этого сообщества"}
|
||||||
|
|
||||||
|
# Обновляем поля сообщества
|
||||||
|
for key, value in community_input.items():
|
||||||
|
# Исключаем изменение created_by - создатель не может быть изменен
|
||||||
|
if hasattr(community, key) and key not in ["slug", "created_by"]:
|
||||||
|
setattr(community, key, value)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
return {"error": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}
|
return {"error": f"Ошибка обновления сообщества: {e!s}"}
|
||||||
return {"ok": True}
|
|
||||||
return {"ok": False, "error": "Please, set community slug in input"}
|
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("delete_community")
|
@mutation.field("delete_community")
|
||||||
|
@editor_or_admin_required
|
||||||
async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
|
async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
|
||||||
author_dict = info.context.get("author", {})
|
# Получаем author_id из контекста через декоратор авторизации
|
||||||
author_id = author_dict.get("id")
|
request = info.context.get("request")
|
||||||
with local_session() as session:
|
author_id = None
|
||||||
|
|
||||||
|
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
|
||||||
|
author_id = request.auth.author_id
|
||||||
|
elif hasattr(request, "scope") and "auth" in request.scope:
|
||||||
|
auth_info = request.scope.get("auth", {})
|
||||||
|
if isinstance(auth_info, dict):
|
||||||
|
author_id = auth_info.get("author_id")
|
||||||
|
elif hasattr(auth_info, "author_id"):
|
||||||
|
author_id = auth_info.author_id
|
||||||
|
|
||||||
|
if not author_id:
|
||||||
|
return {"error": "Не удалось определить автора"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session.query(Community).where(Community.slug == slug, Community.created_by == author_id).delete()
|
with local_session() as session:
|
||||||
|
# Находим сообщество для удаления
|
||||||
|
community = session.query(Community).filter(Community.slug == slug).first()
|
||||||
|
if not community:
|
||||||
|
return {"error": "Сообщество не найдено"}
|
||||||
|
|
||||||
|
# Проверяем права на удаление (создатель или админ/редактор)
|
||||||
|
with local_session() as auth_session:
|
||||||
|
author = auth_session.query(Author).filter(Author.id == author_id).first()
|
||||||
|
user_roles = [role.id for role in author.roles] if author and author.roles else []
|
||||||
|
|
||||||
|
# Разрешаем удаление если пользователь - создатель или имеет роль admin/editor
|
||||||
|
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
|
||||||
|
return {"error": "Недостаточно прав для удаления этого сообщества"}
|
||||||
|
|
||||||
|
# Удаляем сообщество
|
||||||
|
session.delete(community)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"error": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}
|
return {"error": f"Ошибка удаления сообщества: {e!s}"}
|
||||||
|
|
||||||
|
|
||||||
|
@type_community.field("created_by")
|
||||||
|
def resolve_community_created_by(obj: Community, *_: Any) -> Author:
|
||||||
|
"""
|
||||||
|
Резолвер поля created_by для Community.
|
||||||
|
Возвращает автора, создавшего сообщество.
|
||||||
|
"""
|
||||||
|
# Если связь уже загружена через joinedload и валидна
|
||||||
|
if hasattr(obj, "created_by_author") and obj.created_by_author and obj.created_by_author.id:
|
||||||
|
return obj.created_by_author
|
||||||
|
|
||||||
|
# Критическая ошибка - это не должно происходить после фильтрации в get_communities_all
|
||||||
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
logger.error(f"КРИТИЧЕСКАЯ ОШИБКА: Резолвер created_by вызван для сообщества {obj.id} без валидного автора")
|
||||||
|
error_message = f"Сообщество {obj.id} не имеет валидного создателя"
|
||||||
|
raise ValueError(error_message)
|
||||||
|
|
||||||
|
|
||||||
|
@type_community.field("stat")
|
||||||
|
def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Резолвер поля stat для Community.
|
||||||
|
Возвращает статистику сообщества: количество публикаций, подписчиков и авторов.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import distinct, func
|
||||||
|
|
||||||
|
from orm.shout import Shout, ShoutAuthor
|
||||||
|
|
||||||
|
try:
|
||||||
|
with local_session() as session:
|
||||||
|
# Количество опубликованных публикаций в сообществе
|
||||||
|
shouts_count = (
|
||||||
|
session.query(func.count(Shout.id))
|
||||||
|
.filter(Shout.community == obj.id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Количество подписчиков сообщества
|
||||||
|
followers_count = (
|
||||||
|
session.query(func.count(CommunityFollower.follower))
|
||||||
|
.filter(CommunityFollower.community == obj.id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Количество уникальных авторов, опубликовавших в сообществе
|
||||||
|
authors_count = (
|
||||||
|
session.query(func.count(distinct(ShoutAuthor.author)))
|
||||||
|
.join(Shout, ShoutAuthor.shout == Shout.id)
|
||||||
|
.filter(Shout.community == obj.id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"shouts": int(shouts_count), "followers": int(followers_count), "authors": int(authors_count)}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
logger.error(f"Ошибка при получении статистики сообщества {obj.id}: {e}")
|
||||||
|
# Возвращаем нулевую статистику при ошибке
|
||||||
|
return {"shouts": 0, "followers": 0, "authors": 0}
|
||||||
|
|
|
@ -11,6 +11,7 @@ from cache.cache import (
|
||||||
get_cached_topic_by_slug,
|
get_cached_topic_by_slug,
|
||||||
get_cached_topic_followers,
|
get_cached_topic_followers,
|
||||||
invalidate_cache_by_prefix,
|
invalidate_cache_by_prefix,
|
||||||
|
invalidate_topic_followers_cache,
|
||||||
)
|
)
|
||||||
from orm.reaction import Reaction, ReactionKind
|
from orm.reaction import Reaction, ReactionKind
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
|
@ -446,3 +447,55 @@ async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> li
|
||||||
topic = await get_cached_topic_by_slug(slug, get_with_stat)
|
topic = await get_cached_topic_by_slug(slug, get_with_stat)
|
||||||
topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None
|
topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None
|
||||||
return await get_cached_topic_authors(topic_id) if topic_id else []
|
return await get_cached_topic_authors(topic_id) if topic_id else []
|
||||||
|
|
||||||
|
|
||||||
|
# Мутация для удаления темы по ID (для админ-панели)
|
||||||
|
@mutation.field("delete_topic_by_id")
|
||||||
|
@login_required
|
||||||
|
async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Удаляет тему по ID. Используется в админ-панели.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic_id: ID темы для удаления
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Результат операции
|
||||||
|
"""
|
||||||
|
viewer_id = info.context.get("author", {}).get("id")
|
||||||
|
with local_session() as session:
|
||||||
|
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
return {"success": False, "message": "Топик не найден"}
|
||||||
|
|
||||||
|
author = session.query(Author).filter(Author.id == viewer_id).first()
|
||||||
|
if not author:
|
||||||
|
return {"success": False, "message": "Не авторизован"}
|
||||||
|
|
||||||
|
# TODO: проверить права администратора
|
||||||
|
# Для админ-панели допускаем удаление любых топиков администратором
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Инвалидируем кеши подписчиков ПЕРЕД удалением данных из БД
|
||||||
|
await invalidate_topic_followers_cache(topic_id)
|
||||||
|
|
||||||
|
# Удаляем связанные данные (подписчики, связи с публикациями)
|
||||||
|
session.query(TopicFollower).filter(TopicFollower.topic == topic_id).delete()
|
||||||
|
session.query(ShoutTopic).filter(ShoutTopic.topic == topic_id).delete()
|
||||||
|
|
||||||
|
# Удаляем сам топик
|
||||||
|
session.delete(topic)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем основные кеши топика
|
||||||
|
await invalidate_topics_cache(topic_id)
|
||||||
|
if topic.slug:
|
||||||
|
await redis.execute("DEL", f"topic:slug:{topic.slug}")
|
||||||
|
|
||||||
|
logger.info(f"Топик {topic_id} успешно удален")
|
||||||
|
return {"success": True, "message": "Топик успешно удален"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"Ошибка при удалении топика {topic_id}: {e}")
|
||||||
|
return {"success": False, "message": f"Ошибка при удалении: {e!s}"}
|
||||||
|
|
|
@ -31,6 +31,9 @@ type AdminUserInfo {
|
||||||
|
|
||||||
input AdminUserUpdateInput {
|
input AdminUserUpdateInput {
|
||||||
id: Int!
|
id: Int!
|
||||||
|
email: String
|
||||||
|
name: String
|
||||||
|
slug: String
|
||||||
roles: [String!]
|
roles: [String!]
|
||||||
community: Int
|
community: Int
|
||||||
}
|
}
|
||||||
|
@ -43,7 +46,7 @@ type Role {
|
||||||
|
|
||||||
# Тип для пагинированного ответа пользователей
|
# Тип для пагинированного ответа пользователей
|
||||||
type AdminUserListResponse {
|
type AdminUserListResponse {
|
||||||
users: [AdminUserInfo!]!
|
authors: [AdminUserInfo!]!
|
||||||
total: Int!
|
total: Int!
|
||||||
page: Int!
|
page: Int!
|
||||||
perPage: Int!
|
perPage: Int!
|
||||||
|
|
|
@ -21,6 +21,8 @@ input TopicInput {
|
||||||
title: String
|
title: String
|
||||||
body: String
|
body: String
|
||||||
pic: String
|
pic: String
|
||||||
|
community: Int
|
||||||
|
parent_ids: [Int]
|
||||||
}
|
}
|
||||||
|
|
||||||
input DraftInput {
|
input DraftInput {
|
||||||
|
|
|
@ -36,6 +36,7 @@ type Mutation {
|
||||||
create_topic(topic_input: TopicInput!): CommonResult!
|
create_topic(topic_input: TopicInput!): CommonResult!
|
||||||
update_topic(topic_input: TopicInput!): CommonResult!
|
update_topic(topic_input: TopicInput!): CommonResult!
|
||||||
delete_topic(slug: String!): CommonResult!
|
delete_topic(slug: String!): CommonResult!
|
||||||
|
delete_topic_by_id(id: Int!): CommonResult!
|
||||||
|
|
||||||
# reaction
|
# reaction
|
||||||
create_reaction(reaction: ReactionInput!): CommonResult!
|
create_reaction(reaction: ReactionInput!): CommonResult!
|
||||||
|
|
|
@ -66,7 +66,7 @@ type Query {
|
||||||
|
|
||||||
# topic
|
# topic
|
||||||
get_topic(slug: String!): Topic
|
get_topic(slug: String!): Topic
|
||||||
get_topics_all: [Topic]
|
get_topics_all: [Topic]!
|
||||||
get_topics_by_author(slug: String, user: String, author_id: Int): [Topic]
|
get_topics_by_author(slug: String, user: String, author_id: Int): [Topic]
|
||||||
get_topics_by_community(community_id: Int!, limit: Int, offset: Int): [Topic]
|
get_topics_by_community(community_id: Int!, limit: Int, offset: Int): [Topic]
|
||||||
|
|
||||||
|
|
|
@ -189,6 +189,8 @@ type Topic {
|
||||||
title: String
|
title: String
|
||||||
body: String
|
body: String
|
||||||
pic: String
|
pic: String
|
||||||
|
community: Int
|
||||||
|
parent_ids: [Int]
|
||||||
stat: TopicStat
|
stat: TopicStat
|
||||||
oid: String
|
oid: String
|
||||||
is_main: Boolean
|
is_main: Boolean
|
||||||
|
|
|
@ -8,7 +8,8 @@ from services.db import create_table_if_not_exists, local_session
|
||||||
query = QueryType()
|
query = QueryType()
|
||||||
mutation = MutationType()
|
mutation = MutationType()
|
||||||
type_draft = ObjectType("Draft")
|
type_draft = ObjectType("Draft")
|
||||||
resolvers: List[SchemaBindable] = [query, mutation, type_draft]
|
type_community = ObjectType("Community")
|
||||||
|
resolvers: List[SchemaBindable] = [query, mutation, type_draft, type_community]
|
||||||
|
|
||||||
|
|
||||||
def create_all_tables() -> None:
|
def create_all_tables() -> None:
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
"lib": ["DOM", "ESNext"],
|
"lib": ["DOM", "ESNext"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./panel/*"]
|
"~/*": ["./panel/*"]
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"exclude": []
|
"typeRoots": ["./panel/types", "./node_modules/@types"]
|
||||||
|
},
|
||||||
|
"include": ["panel/**/*.ts", "panel/**/*.tsx", "panel/**/*.d.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'node:path'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import solidPlugin from 'vite-plugin-solid'
|
import solidPlugin from 'vite-plugin-solid'
|
||||||
|
|
||||||
|
@ -7,11 +7,15 @@ const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [solidPlugin()],
|
plugins: [solidPlugin()],
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
minify: isProd,
|
assetsDir: 'assets',
|
||||||
|
emptyOutDir: true,
|
||||||
sourcemap: !isProd,
|
sourcemap: !isProd,
|
||||||
|
minify: isProd ? 'terser' : false,
|
||||||
|
cssMinify: isProd ? 'lightningcss' : false,
|
||||||
|
|
||||||
// Оптимизация сборки
|
// Оптимизация сборки
|
||||||
cssCodeSplit: true,
|
cssCodeSplit: true,
|
||||||
|
@ -36,7 +40,7 @@ export default defineConfig({
|
||||||
|
|
||||||
// Оптимизация зависимостей
|
// Оптимизация зависимостей
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['solid-js', '@solidjs/router'],
|
include: ['solid-js'],
|
||||||
exclude: []
|
exclude: []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user