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
|
||||
|
||||
node_modules/
|
||||
panel/graphql/generated/
|
||||
panel/types.gen.ts
|
||||
|
||||
.cursorrules
|
||||
.cursor/
|
||||
|
|
156
CHANGELOG.md
156
CHANGELOG.md
|
@ -1,5 +1,159 @@
|
|||
# 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
|
||||
|
||||
### Новая функциональность админ-панели
|
||||
|
@ -466,7 +620,7 @@
|
|||
- Modified `load_reactions_by` to include deleted reactions when `include_deleted=true` for proper comment tree building
|
||||
- Fixed featured/unfeatured logic in reaction processing:
|
||||
- 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
|
||||
- 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:
|
||||
"""
|
||||
Декоратор для резолверов, которые могут работать как с авторизованными,
|
||||
так и с неавторизованными пользователями.
|
||||
|
||||
Добавляет информацию о пользователе в контекст, если пользователь авторизован.
|
||||
Декоратор для проверки аутентификации пользователя.
|
||||
|
||||
Args:
|
||||
func: Декорируемая функция
|
||||
func: функция-резолвер для декорирования
|
||||
|
||||
Returns:
|
||||
Callable: обернутая функция
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
# Пробуем проверить авторизацию, но не выбрасываем исключение, если пользователь не авторизован
|
||||
try:
|
||||
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] Пользователь не авторизован")
|
||||
|
||||
await validate_graphql_context(info)
|
||||
return await func(parent, info, *args, **kwargs)
|
||||
except Exception as e:
|
||||
if not isinstance(e, GraphQLError):
|
||||
logger.error(f"[login_accepted] Ошибка: {e}")
|
||||
except GraphQLError:
|
||||
# Пробрасываем ошибки авторизации далее
|
||||
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
|
||||
|
|
|
@ -200,14 +200,14 @@ async def _fetch_facebook_profile(client: Any, token: Any) -> dict:
|
|||
|
||||
async def _fetch_x_profile(client: Any, token: Any) -> dict:
|
||||
"""Получает профиль из 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()
|
||||
return PROVIDER_HANDLERS["x"](token, profile_data)
|
||||
|
||||
|
||||
async def _fetch_vk_profile(client: Any, token: Any) -> dict:
|
||||
"""Получает профиль из 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()
|
||||
if profile_data.get("response"):
|
||||
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": {
|
||||
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
|
||||
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
|
||||
"includes": [
|
||||
"**/*.tsx",
|
||||
"**/*.ts",
|
||||
"**/*.js",
|
||||
"**/*.json",
|
||||
"!dist",
|
||||
"!node_modules",
|
||||
"!**/.husky",
|
||||
"!**/docs",
|
||||
"!**/gen",
|
||||
"!**/*.gen.ts",
|
||||
"!**/*.d.ts"
|
||||
]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
|
@ -10,16 +21,13 @@
|
|||
"useIgnoreFile": true,
|
||||
"clientKind": "git"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true,
|
||||
"ignore": ["./gen"]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 108,
|
||||
"ignore": ["./src/graphql/schema", "./gen"]
|
||||
"includes": ["**", "!src/graphql/schema", "!gen", "!panel/graphql/generated"]
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
|
@ -33,11 +41,11 @@
|
|||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"],
|
||||
"includes": ["**", "!**/*.scss", "!**/*.md", "!**/.DS_Store", "!**/*.svg", "!**/*.d.ts"],
|
||||
"rules": {
|
||||
"all": true,
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noUselessFragments": "off",
|
||||
"useOptionalChain": "warn",
|
||||
"useLiteralKeys": "off",
|
||||
"noExcessiveCognitiveComplexity": "off",
|
||||
|
@ -46,10 +54,7 @@
|
|||
"correctness": {
|
||||
"useHookAtTopLevel": "off",
|
||||
"useImportExtensions": "off",
|
||||
"noUndeclaredDependencies": "off",
|
||||
"noNodejsModules": {
|
||||
"level": "off"
|
||||
}
|
||||
"noUndeclaredDependencies": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useHeadingContent": "off",
|
||||
|
@ -61,18 +66,16 @@
|
|||
"useAltText": "off",
|
||||
"useButtonType": "off",
|
||||
"noRedundantAlt": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"noLabelWithoutControl": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"useImportRestrictions": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noBarrelFile": "off"
|
||||
"noBarrelFile": "off",
|
||||
"noNamespaceImport": "warn"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"noNamespaceImport": "warn",
|
||||
"noUselessElse": "off",
|
||||
"useBlockStatements": "off",
|
||||
"noImplicitBoolean": "off",
|
||||
|
@ -81,12 +84,25 @@
|
|||
"noDefaultExport": "off",
|
||||
"useFilenamingConvention": "off",
|
||||
"useExplicitLengthCheck": "off",
|
||||
"useNodejsImportProtocol": "off"
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"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 = []
|
||||
for author in shout.authors:
|
||||
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))
|
||||
tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
|
||||
tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
|
||||
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:
|
||||
"""Очищает весь кеш (использовать осторожно)"""
|
||||
"""
|
||||
Очищает весь кэш Redis (используйте с осторожностью!)
|
||||
|
||||
Warning:
|
||||
Эта функция удаляет ВСЕ данные из Redis!
|
||||
Используйте только в тестовой среде или при критической необходимости.
|
||||
"""
|
||||
try:
|
||||
# Get all cache keys
|
||||
topic_keys = await redis.keys("topic:*")
|
||||
author_keys = await redis.keys("author:*")
|
||||
search_keys = await redis.keys("search:*")
|
||||
follows_keys = await redis.keys("follows:*")
|
||||
await redis.execute("FLUSHDB")
|
||||
logger.info("Весь кэш очищен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке кэша: {e}")
|
||||
|
||||
all_keys = topic_keys + author_keys + search_keys + follows_keys
|
||||
|
||||
if all_keys:
|
||||
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")
|
||||
async def invalidate_topic_followers_cache(topic_id: int) -> None:
|
||||
"""
|
||||
Инвалидирует кеши подписчиков при удалении топика.
|
||||
|
||||
Эта функция:
|
||||
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:
|
||||
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, авторы, темы с удобной навигацией
|
||||
- **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 для отслеживания просмотров публикаций
|
||||
|
|
|
@ -42,7 +42,7 @@ Unfollow an entity.
|
|||
### Queries
|
||||
|
||||
#### get_shout_followers
|
||||
Get list of users who reacted to a shout.
|
||||
Get list of authors who reacted to a shout.
|
||||
|
||||
**Parameters:**
|
||||
- `slug: String` - Shout slug
|
||||
|
|
|
@ -34,7 +34,7 @@ JWT_EXPIRATION_HOURS=24
|
|||
-- Create oauth_links table
|
||||
CREATE TABLE oauth_links (
|
||||
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_id VARCHAR(255) NOT NULL,
|
||||
provider_data JSONB,
|
||||
|
|
|
@ -295,7 +295,7 @@ async def migrate_oauth_tokens():
|
|||
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
|
||||
|
|
|
@ -18,7 +18,7 @@ class CommunityRole(enum.Enum):
|
|||
ADMIN = "admin"
|
||||
|
||||
@classmethod
|
||||
def as_string_array(cls, roles):
|
||||
def as_string_array(cls, roles) -> list[str]:
|
||||
return [role.value for role in roles]
|
||||
|
||||
@classmethod
|
||||
|
@ -59,6 +59,7 @@ class Community(BaseModel):
|
|||
private = Column(Boolean, default=False)
|
||||
|
||||
followers = relationship("Author", secondary="community_follower")
|
||||
created_by_author = relationship("Author", foreign_keys=[created_by])
|
||||
|
||||
@hybrid_property
|
||||
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",
|
||||
"version": "0.4.22",
|
||||
"name": "publy-panel",
|
||||
"version": "0.5.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
@ -8,20 +8,33 @@
|
|||
"serve": "vite preview",
|
||||
"lint": "biome check . --fix",
|
||||
"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": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^22.15.0",
|
||||
"@biomejs/biome": "^2.0.6",
|
||||
"@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",
|
||||
"graphql": "^16.8.0",
|
||||
"solid-js": "^1.9.6",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"lightningcss": "^1.30.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"solid-js": "^1.9.7",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-solid": "^2.11.7"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"prismjs": "^1.30.0"
|
||||
"@solidjs/router": "^0.15.3"
|
||||
}
|
||||
}
|
||||
|
|
170
panel/App.tsx
170
panel/App.tsx
|
@ -1,105 +1,89 @@
|
|||
import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js'
|
||||
import { isAuthenticated, getAuthTokenFromCookie } from './auth'
|
||||
import { Route, Router } from '@solidjs/router'
|
||||
import { lazy, onMount, Suspense } from 'solid-js'
|
||||
import { AuthProvider, useAuth } from './context/auth'
|
||||
|
||||
// Ленивая загрузка компонентов
|
||||
const AdminPage = lazy(() => import('./admin'))
|
||||
const LoginPage = lazy(() => import('./login'))
|
||||
const AdminPage = lazy(() => {
|
||||
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 [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [checkingAuth, setCheckingAuth] = createSignal(true)
|
||||
const ProtectedRoute = () => {
|
||||
console.log('[ProtectedRoute] Checking authentication...')
|
||||
const auth = useAuth()
|
||||
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 (
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Проверка авторизации...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="app-container">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<h2>Загрузка компонентов...</h2>
|
||||
</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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
1734
panel/admin.tsx
1734
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;
|
||||
margin: 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 {
|
||||
/* Основные цвета */
|
||||
--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-radius-sm: 4px;
|
||||
|
@ -52,11 +81,10 @@
|
|||
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||
|
||||
/* Размеры */
|
||||
--container-max-width: 1200px;
|
||||
--container-max-width: 1400px;
|
||||
--header-height: 60px;
|
||||
|
||||
/* Анимации */
|
||||
--transition-fast: 0.2s ease;
|
||||
--transition-normal: 0.3s ease;
|
||||
|
||||
/* Z-индексы */
|
||||
|
@ -83,29 +111,34 @@ body {
|
|||
}
|
||||
|
||||
/* Общие элементы интерфейса */
|
||||
.loading-screen, .loading {
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--primary-color);
|
||||
min-height: 100vh;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color-light);
|
||||
font-size: var(--font-size-lg);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary-color);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 20px;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
|
@ -168,34 +201,27 @@ body {
|
|||
}
|
||||
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
button:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #E5E9F2;
|
||||
color: #A0AEC0;
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Стили для страницы входа */
|
||||
|
@ -329,7 +355,7 @@ header h1 {
|
|||
}
|
||||
|
||||
main {
|
||||
padding: 20px;
|
||||
padding: 1.5rem 3rem;
|
||||
max-width: var(--container-max-width);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
|
@ -337,7 +363,7 @@ main {
|
|||
}
|
||||
|
||||
/* Таблица пользователей */
|
||||
.users-list {
|
||||
.authors-list {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
@ -351,6 +377,7 @@ table {
|
|||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--card-bg);
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
thead {
|
||||
|
@ -358,10 +385,11 @@ thead {
|
|||
}
|
||||
|
||||
th, td {
|
||||
padding: 14px 16px;
|
||||
padding: 18px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
th {
|
||||
|
@ -369,7 +397,7 @@ th {
|
|||
color: var(--text-secondary);
|
||||
background-color: #F5F7FA;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
|
@ -710,12 +738,12 @@ tr:hover {
|
|||
}
|
||||
|
||||
/* Поиск */
|
||||
.users-controls {
|
||||
.authors-controls {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
@ -771,7 +799,7 @@ tr:hover {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
.authors-list {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
@ -1117,7 +1145,7 @@ th.sortable.sorted .sort-icon {
|
|||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.users-list,
|
||||
.authors-list,
|
||||
.shouts-list table {
|
||||
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 {
|
||||
outline: 2px solid var(--primary-color);
|
||||
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 'лет'
|
||||
}
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules", "**/__pycache__", "**/.*"],
|
||||
"defineConstant": {
|
||||
"DEBUG": true
|
||||
},
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.11",
|
||||
"typeCheckingMode": "strict",
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": false,
|
||||
"reportUnknownMemberType": false,
|
||||
"reportUnknownParameterType": false,
|
||||
"reportUnknownVariableType": false,
|
||||
"reportUnknownArgumentType": false,
|
||||
"reportPrivateUsage": false,
|
||||
"reportUntypedFunctionDecorator": false
|
||||
}
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules", "**/__pycache__", "**/.*"],
|
||||
"defineConstant": {
|
||||
"DEBUG": true
|
||||
},
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.11",
|
||||
"typeCheckingMode": "strict",
|
||||
"reportMissingImports": true,
|
||||
"reportMissingTypeStubs": false,
|
||||
"reportUnknownMemberType": false,
|
||||
"reportUnknownParameterType": false,
|
||||
"reportUnknownVariableType": false,
|
||||
"reportUnknownArgumentType": false,
|
||||
"reportPrivateUsage": false,
|
||||
"reportUntypedFunctionDecorator": false
|
||||
}
|
||||
|
|
|
@ -62,11 +62,11 @@ async def admin_get_users(
|
|||
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
|
||||
return {
|
||||
"users": [
|
||||
"authors": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
|
@ -76,7 +76,7 @@ async def admin_get_users(
|
|||
"created_at": user.created_at,
|
||||
"last_seen": user.last_seen,
|
||||
}
|
||||
for user in users
|
||||
for user in authors
|
||||
],
|
||||
"total": total_count,
|
||||
"page": current_page,
|
||||
|
@ -247,11 +247,11 @@ async def update_env_variables(_: None, info: GraphQLResolveInfo, variables: lis
|
|||
@admin_auth_required
|
||||
async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Обновляет роли пользователя
|
||||
Обновляет данные пользователя (роли, email, имя, slug)
|
||||
|
||||
Args:
|
||||
info: Контекст GraphQL запроса
|
||||
user: Данные для обновления пользователя (содержит id и roles)
|
||||
user: Данные для обновления пользователя
|
||||
|
||||
Returns:
|
||||
Boolean: результат операции или объект с ошибкой
|
||||
|
@ -259,6 +259,9 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
|
|||
try:
|
||||
user_id = user.get("id")
|
||||
roles = user.get("roles", [])
|
||||
email = user.get("email")
|
||||
name = user.get("name")
|
||||
slug = user.get("slug")
|
||||
|
||||
if not roles:
|
||||
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)
|
||||
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 сообщества по умолчанию
|
||||
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'. Доступ в систему будет ограничен."
|
||||
)
|
||||
|
||||
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}
|
||||
except Exception as e:
|
||||
# Обработка вложенных исключений
|
||||
session.rollback()
|
||||
error_msg = f"Ошибка при изменении ролей: {e!s}"
|
||||
error_msg = f"Ошибка при изменении данных пользователя: {e!s}"
|
||||
logger.error(error_msg)
|
||||
return {"success": False, "error": error_msg}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_msg = f"Ошибка при обновлении ролей пользователя: {e!s}"
|
||||
error_msg = f"Ошибка при обновлении данных пользователя: {e!s}"
|
||||
logger.error(error_msg)
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "error": error_msg}
|
||||
|
|
|
@ -2,15 +2,49 @@ from typing import Any
|
|||
|
||||
from graphql import GraphQLResolveInfo
|
||||
|
||||
from auth.decorators import editor_or_admin_required
|
||||
from auth.orm import Author
|
||||
from orm.community import Community, CommunityFollower
|
||||
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")
|
||||
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")
|
||||
|
@ -63,41 +97,192 @@ async def leave_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[
|
|||
|
||||
|
||||
@mutation.field("create_community")
|
||||
async def create_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]:
|
||||
author_dict = info.context.get("author", {})
|
||||
author_id = author_dict.get("id")
|
||||
with local_session() as session:
|
||||
session.add(Community(author=author_id, **community_data))
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
@editor_or_admin_required
|
||||
async def create_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
|
||||
# Получаем 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:
|
||||
# Исключаем 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()
|
||||
return {"error": None}
|
||||
except Exception as e:
|
||||
return {"error": f"Ошибка создания сообщества: {e!s}"}
|
||||
|
||||
|
||||
@mutation.field("update_community")
|
||||
async def update_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]:
|
||||
author_dict = info.context.get("author", {})
|
||||
author_id = author_dict.get("id")
|
||||
slug = community_data.get("slug")
|
||||
if slug:
|
||||
@editor_or_admin_required
|
||||
async def update_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
|
||||
# Получаем 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": "Не удалось определить автора"}
|
||||
|
||||
slug = community_input.get("slug")
|
||||
if not slug:
|
||||
return {"error": "Не указан slug сообщества"}
|
||||
|
||||
try:
|
||||
with local_session() as session:
|
||||
try:
|
||||
session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update(
|
||||
community_data
|
||||
)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "Please, set community slug in input"}
|
||||
# Находим сообщество для обновления
|
||||
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()
|
||||
return {"error": None}
|
||||
except Exception as e:
|
||||
return {"error": f"Ошибка обновления сообщества: {e!s}"}
|
||||
|
||||
|
||||
@mutation.field("delete_community")
|
||||
@editor_or_admin_required
|
||||
async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
|
||||
author_dict = info.context.get("author", {})
|
||||
author_id = author_dict.get("id")
|
||||
with local_session() as session:
|
||||
try:
|
||||
session.query(Community).where(Community.slug == slug, Community.created_by == author_id).delete()
|
||||
# Получаем 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:
|
||||
# Находим сообщество для удаления
|
||||
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()
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"error": None}
|
||||
except Exception as 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_followers,
|
||||
invalidate_cache_by_prefix,
|
||||
invalidate_topic_followers_cache,
|
||||
)
|
||||
from orm.reaction import Reaction, ReactionKind
|
||||
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_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 []
|
||||
|
||||
|
||||
# Мутация для удаления темы по 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 {
|
||||
id: Int!
|
||||
email: String
|
||||
name: String
|
||||
slug: String
|
||||
roles: [String!]
|
||||
community: Int
|
||||
}
|
||||
|
@ -43,7 +46,7 @@ type Role {
|
|||
|
||||
# Тип для пагинированного ответа пользователей
|
||||
type AdminUserListResponse {
|
||||
users: [AdminUserInfo!]!
|
||||
authors: [AdminUserInfo!]!
|
||||
total: Int!
|
||||
page: Int!
|
||||
perPage: Int!
|
||||
|
|
|
@ -21,6 +21,8 @@ input TopicInput {
|
|||
title: String
|
||||
body: String
|
||||
pic: String
|
||||
community: Int
|
||||
parent_ids: [Int]
|
||||
}
|
||||
|
||||
input DraftInput {
|
||||
|
|
|
@ -36,6 +36,7 @@ type Mutation {
|
|||
create_topic(topic_input: TopicInput!): CommonResult!
|
||||
update_topic(topic_input: TopicInput!): CommonResult!
|
||||
delete_topic(slug: String!): CommonResult!
|
||||
delete_topic_by_id(id: Int!): CommonResult!
|
||||
|
||||
# reaction
|
||||
create_reaction(reaction: ReactionInput!): CommonResult!
|
||||
|
|
|
@ -66,7 +66,7 @@ type Query {
|
|||
|
||||
# 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_community(community_id: Int!, limit: Int, offset: Int): [Topic]
|
||||
|
||||
|
|
|
@ -189,6 +189,8 @@ type Topic {
|
|||
title: String
|
||||
body: String
|
||||
pic: String
|
||||
community: Int
|
||||
parent_ids: [Int]
|
||||
stat: TopicStat
|
||||
oid: String
|
||||
is_main: Boolean
|
||||
|
|
|
@ -8,7 +8,8 @@ from services.db import create_table_if_not_exists, local_session
|
|||
query = QueryType()
|
||||
mutation = MutationType()
|
||||
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:
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
"lib": ["DOM", "ESNext"],
|
||||
"paths": {
|
||||
"~/*": ["./panel/*"]
|
||||
}
|
||||
},
|
||||
"typeRoots": ["./panel/types", "./node_modules/@types"]
|
||||
},
|
||||
"exclude": []
|
||||
"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 solidPlugin from 'vite-plugin-solid'
|
||||
|
||||
|
@ -7,11 +7,15 @@ const isProd = process.env.NODE_ENV === 'production'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [solidPlugin()],
|
||||
|
||||
build: {
|
||||
target: 'esnext',
|
||||
outDir: 'dist',
|
||||
minify: isProd,
|
||||
assetsDir: 'assets',
|
||||
emptyOutDir: true,
|
||||
sourcemap: !isProd,
|
||||
minify: isProd ? 'terser' : false,
|
||||
cssMinify: isProd ? 'lightningcss' : false,
|
||||
|
||||
// Оптимизация сборки
|
||||
cssCodeSplit: true,
|
||||
|
@ -36,7 +40,7 @@ export default defineConfig({
|
|||
|
||||
// Оптимизация зависимостей
|
||||
optimizeDeps: {
|
||||
include: ['solid-js', '@solidjs/router'],
|
||||
include: ['solid-js'],
|
||||
exclude: []
|
||||
},
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user