0.5.8-panel-upgrade-community-crud-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-06-30 21:25:26 +03:00
parent 9de86c0fae
commit 952b294345
70 changed files with 11345 additions and 2655 deletions

5
.gitignore vendored
View File

@ -164,3 +164,8 @@ views.json
.cursor .cursor
node_modules/ node_modules/
panel/graphql/generated/
panel/types.gen.ts
.cursorrules
.cursor/

View File

@ -1,5 +1,159 @@
# Changelog # Changelog
## [0.5.8] - 2025-06-30
### Улучшения интерфейса публикаций
- **НОВОЕ**: Статусы публикаций иконками:
- **Опубликовано**: ✅ (зелёный бэдж) - быстрая визуальная идентификация опубликованных статей
- **Черновик**: 📝 (жёлтый бэдж) - чёткое обозначение незавершённых публикаций
- **Удалено**: 🗑️ (красный бэдж) - явное указание на удалённые материалы
- **Компактный дизайн**: Статус-бэджи 32×32px с центрированными иконками для экономии места
- **Tooltip поддержка**: При наведении показывается текстовое описание статуса для полной ясности
- **УЛУЧШЕНО**: Выравнивание элементов управления:
- **Логичная группировка**: Поиск и элементы управления размещены в одной строке слева направо
- **Убран разброс**: Элементы больше не разбросаны по разным концам экрана (`justify-content: space-between`)
- **Удалён фильтр статуса**: Упрощён интерфейс за счёт удаления избыточного селектора фильтрации
- **Flex gap**: Равномерные отступы 1.5rem между элементами управления
- **Responsive дизайн**: Элементы корректно переносятся на мобильных устройствах (`flex-wrap`)
- **Архитектурные улучшения**:
- **Функция getShoutStatusTitle()**: Отдельная функция для получения текстового описания статуса
- **Обновлённые CSS классы**: Модернизированные стили для status-badge с flexbox центрированием
- **Лучшая семантика**: Title атрибуты для accessibility и пользовательского опыта
### Сортировка топиков и управление сообществами
- **НОВОЕ**: Сортировка топиков в админ-панели:
- **Выпадающий селектор**: Выбор между сортировкой по ID и названию
- **Направление сортировки**: По возрастанию/убыванию с интуитивными стрелочками ↑↓
- **Умная русская сортировка**: Использование `localeCompare('ru')` для корректной сортировки русских названий
- **Рекурсивная сортировка**: Дочерние топики также сортируются по выбранному критерию
- **Реактивность**: Автоматическое пересортирование при изменении параметров
- **Сохранение иерархии**: Древовидная структура сохраняется при любом типе сортировки
- **НОВОЕ**: Полноценное управление сообществами:
- **Новая вкладка "Сообщества"**: Отдельная секция в админ-панели для управления сообществами
- **Подробная таблица**: ID, название, slug, описание, создатель, статистика (публикации/подписчики/авторы), дата создания
- **Клик для редактирования**: Нажатие на строку открывает модалку редактирования сообщества
- **Удаление с подтверждением**: Тонкая кнопка "×" для удаления с двойным подтверждением
- **Полная CRUD функциональность**: Создание, редактирование, удаление сообществ
- **Исправлена проблема с загрузкой**: Добавлен relationship для `created_by` в ORM модели Community
- **Резолвер поля created_by**: Корректное получение информации о создателе сообщества
### Улучшенное управление пользователями
- **КАРДИНАЛЬНО НОВАЯ модалка редактирования пользователя**:
- **Красивый современный дизайн**: Карточки для ролей, секционное разделение, современная типографика
- **Полное редактирование профиля**: Email, имя, slug, роли (не только роли как раньше)
- **Умная валидация**: Проверка email, обязательных полей, уникальности slug
- **Информационная панель**: Отображение ID, даты регистрации, последней активности
- **Интерактивные карточки ролей**: Описание каждой роли с иконками состояния
- **Расширенная GraphQL схема**: `AdminUserUpdateInput` теперь поддерживает email, name, slug
- **Улучшенный резолвер**: `adminUpdateUser` обрабатывает профильные поля с проверкой уникальности
- **Реальная валидация**: Проверка email и slug на уникальность в базе данных
- **Детальное логирование**: Подробные сообщения об изменениях в профиле и ролях
- **ТЕХНИЧЕСКАЯ АРХИТЕКТУРА**:
- **Переименование компонента**: `RolesModal``UserEditModal` для отражения расширенного функционала
- **Новые CSS стили**: Добавлены стили для форм, карточек ролей, валидации в `Form.module.css`
- **Обновленный API интерфейс**: `onSave` теперь принимает полный объект пользователя вместо только ролей
- **Реактивная форма**: Автоочистка ошибок при изменении полей, сброс состояния при открытии
### Полноценное редактирование топиков в админ-панели
- **НОВОЕ**: Редактирование всех полей топиков:
- **Колонка ID**: Отображение идентификаторов топиков в таблице для точной идентификации
- **Редактирование названия**: Изменение `title` прямо в модальном окне
- **Простой HTML редактор**: Обычный `contenteditable` div вместо сложного редактора кода
- **Управление сообществом**: Изменение `community` ID с валидацией
- **Управление иерархией**: Редактирование `parent_ids` (список родительских топиков через запятую)
- **Картинки**: Редактирование URL картинки (`pic`)
- **Улучшения UI/UX**:
- **Клик по строке для редактирования**: Убрана кнопка "Редактировать", модалка открывается кликом на любом месте строки
- **Ненавязчивый крестик удаления**: Простая кнопка "×" серого цвета, которая становится красной при наведении
- **Колонка "Родители"**: Отображение списка parent_ids в основной таблице
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом и placeholder
- **Подтверждение удаления**: Модальное окно при клике на крестик
- **Архитектурные улучшения**:
- **TopicInput расширен**: Добавлены поля `community` и `parent_ids` в GraphQL схему
- **Новые мутации**: `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION` в mutations.ts
- **TopicEditModal**: Переиспользуемый компонент с простым интерфейсом
- **Парсинг parent_ids**: Автоматическое преобразование строки "1, 5, 12" в массив чисел
- **Синхронизация данных**: createEffect для синхронизации формы с выбранным топиком
- **Технические детали**:
- **Кликабельные строки**: Hover эффект и cursor pointer для лучшего UX
- **Prevent event bubbling**: Правильная обработка клика на крестике без открытия модалки
- **CSS стили**: Стили для hover эффектов крестика и placeholder в contenteditable
- **Валидация**: Обязательное поле `slug`, проверка числовых полей
- **Обработка ошибок**: Корректное отображение ошибок GraphQL
- **Автообновление**: Перезагрузка списка топиков после успешного сохранения
### Рефакторинг админ-панели
- **ИСПРАВЛЕНО**: Переключение табов в админ-панели:
- **Проблема**: Роутинг не работал корректно - табы не переключались при клике
- **Решение**: Заменен `useLocation` на `useParams` для корректного получения активной вкладки
- **Улучшения**: Исправлена логика навигации с `replace: true` для редиректа на `/admin/authors`
- **Результат**: Теперь переключение между табами работает плавно и корректно
- **НОВОЕ**: Управление топиками в админ-панели:
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─`
- **Удаление в один клик**: Кнопка удаления с модальным окном подтверждения
- **Информативная таблица**: Название, slug, описание, сообщество, действия
- **Предупреждения**: Информация о том что дочерние топики также будут удалены
- **Автообновление**: Список перезагружается после успешного удаления
### Codegen рефакторинг
- **GraphQL Codegen**: Настроена автоматическая генерация TypeScript типов:
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
- **Автоматические типы**: Генерация из GraphQL схемы в `panel/graphql/generated/`
- **Структура**: Разделение на queries, mutations и index файлы
- **TypeScript интеграция**: Полная типизация для админ-панели
- **Архитектурные улучшения**:
- **Модульная структура**: Разделение GraphQL операций по назначению
- **Type safety**: Строгая типизация для всех GraphQL операций
- **Developer Experience**: Автокомплит и проверка типов в IDE
### Улучшения системы кеширования
- **НОВОЕ**: Функция `invalidate_topic_followers_cache()` в модуле cache:
- **Централизованная логика**: Все операции по инвалидации кешей подписчиков в одном месте
- **Комплексная обработка**: Инвалидация кешей как самого топика, так и всех его подписчиков
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
- **Подробное логирование**: Отслеживание всех операций инвалидации для отладки
- **Исправлена логика удаления топиков**:
- **Проблема**: При удалении топика не обновлялись счетчики подписок у всех подписчиков
- **Решение**: Добавлена инвалидация персональных кешей для каждого подписчика:
- `author:follows-topics:{follower_id}` - список подписок на топики
- `author:followers:{follower_id}` - счетчики подписчиков
- `author:stat:{follower_id}` - общая статистика автора
- **Результат**: Система поддерживает консистентность кешей при удалении топиков
- **Архитектурные улучшения**:
- **Разделение ответственности**: Cache модуль отвечает за кеширование, резолверы за бизнес-логику
- **Переиспользуемость**: Функцию можно использовать в других операциях с топиками
- **Тестируемость**: Логику кеширования легко мокать и тестировать отдельно
### GraphQL Schema
- **Новые операции**:
- `delete_topic_by_id(id: Int!)` - удаление топика по ID для админ-панели
- Обновленный `get_topics_all` для корректной типизации
### Исправления резолверов
- **Использование существующей схемы**: Приведение кода в соответствие с truth source схемой GraphQL
- **Упрощение**: Убраны дублирующиеся резолверы, используются существующие `get_topics_all`
- **Чистота кода**: Удалена дублированная логика инвалидации кешей
## [0.5.7] - 2025-06-28 ## [0.5.7] - 2025-06-28
### Новая функциональность админ-панели ### Новая функциональность админ-панели
@ -466,7 +620,7 @@
- Modified `load_reactions_by` to include deleted reactions when `include_deleted=true` for proper comment tree building - Modified `load_reactions_by` to include deleted reactions when `include_deleted=true` for proper comment tree building
- Fixed featured/unfeatured logic in reaction processing: - Fixed featured/unfeatured logic in reaction processing:
- Dislike reactions now properly take precedence over likes - Dislike reactions now properly take precedence over likes
- Featured status now requires more than 4 likes from users with featured articles - Featured status now requires more than 4 likes from authors with featured articles
- Removed unnecessary filters for deleted reactions since rating reactions are physically deleted - Removed unnecessary filters for deleted reactions since rating reactions are physically deleted
- Author's featured status now based on having non-deleted articles with featured_at - Author's featured status now based on having non-deleted articles with featured_at

View File

@ -380,49 +380,96 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
def login_accepted(func: Callable) -> Callable: def login_accepted(func: Callable) -> Callable:
""" """
Декоратор для резолверов, которые могут работать как с авторизованными, Декоратор для проверки аутентификации пользователя.
так и с неавторизованными пользователями.
Добавляет информацию о пользователе в контекст, если пользователь авторизован.
Args: Args:
func: Декорируемая функция func: функция-резолвер для декорирования
Returns:
Callable: обернутая функция
""" """
@wraps(func) @wraps(func)
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any: async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
try:
# Пробуем проверить авторизацию, но не выбрасываем исключение, если пользователь не авторизован
try: try:
await validate_graphql_context(info) await validate_graphql_context(info)
except GraphQLError:
# Игнорируем ошибку авторизации
pass
# Получаем объект авторизации
auth = None
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
auth = info.context["request"].scope.get("auth")
if auth and getattr(auth, "logged_in", False):
# Если пользователь авторизован, добавляем информацию о нем в контекст
with local_session() as session:
try:
author = session.query(Author).filter(Author.id == auth.author_id).one()
info.context["author"] = author.dict()
logger.debug(f"[login_accepted] Пользователь авторизован: {author.id}")
except exc.NoResultFound:
logger.warning(f"[login_accepted] Пользователь с ID {auth.author_id} не найден в базе данных")
info.context["author"] = None
else:
# Если пользователь не авторизован, устанавливаем пустые значения
info.context["author"] = None
logger.debug("[login_accepted] Пользователь не авторизован")
return await func(parent, info, *args, **kwargs) return await func(parent, info, *args, **kwargs)
except Exception as e: except GraphQLError:
if not isinstance(e, GraphQLError): # Пробрасываем ошибки авторизации далее
logger.error(f"[login_accepted] Ошибка: {e}")
raise raise
except Exception as e:
logger.error(f"[decorators] Unexpected error in login_accepted: {e}")
msg = "Internal server error"
raise GraphQLError(msg) from e
return wrap
def editor_or_admin_required(func: Callable) -> Callable:
"""
Декоратор для проверки, что пользователь имеет роль 'editor' или 'admin'.
Args:
func: функция-резолвер для декорирования
Returns:
Callable: обернутая функция
"""
@wraps(func)
async def wrap(parent: Any, info: GraphQLResolveInfo, *args: Any, **kwargs: Any) -> Any:
try:
# Сначала проверяем авторизацию
await validate_graphql_context(info)
# Получаем информацию о пользователе
request = info.context.get("request")
author_id = None
# Пробуем получить author_id из разных источников
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
logger.warning("[decorators] Не удалось получить author_id для проверки ролей")
raise GraphQLError("Ошибка авторизации: не удалось определить пользователя")
# Проверяем роли пользователя
with local_session() as session:
author = session.query(Author).filter(Author.id == author_id).first()
if not author:
logger.warning(f"[decorators] Автор с ID {author_id} не найден")
raise GraphQLError("Пользователь не найден")
# Проверяем email админа
if author.email in ADMIN_EMAILS:
logger.debug(f"[decorators] Пользователь {author.email} является админом по email")
return await func(parent, info, *args, **kwargs)
# Получаем список ролей пользователя
user_roles = [role.id for role in author.roles] if author.roles else []
logger.debug(f"[decorators] Роли пользователя {author_id}: {user_roles}")
# Проверяем наличие роли admin или editor
if "admin" in user_roles or "editor" in user_roles:
logger.debug(f"[decorators] Пользователь {author_id} имеет разрешение (роли: {user_roles})")
return await func(parent, info, *args, **kwargs)
# Если нет нужных ролей
logger.warning(f"[decorators] Пользователю {author_id} отказано в доступе. Роли: {user_roles}")
raise GraphQLError("Доступ запрещен. Требуется роль редактора или администратора.")
except GraphQLError:
# Пробрасываем ошибки авторизации далее
raise
except Exception as e:
logger.error(f"[decorators] Неожиданная ошибка в editor_or_admin_required: {e}")
raise GraphQLError("Внутренняя ошибка сервера") from e
return wrap return wrap

View File

@ -200,14 +200,14 @@ async def _fetch_facebook_profile(client: Any, token: Any) -> dict:
async def _fetch_x_profile(client: Any, token: Any) -> dict: async def _fetch_x_profile(client: Any, token: Any) -> dict:
"""Получает профиль из X (Twitter) API""" """Получает профиль из X (Twitter) API"""
profile = await client.get("users/me?user.fields=id,name,username,profile_image_url", token=token) profile = await client.get("authors/me?user.fields=id,name,username,profile_image_url", token=token)
profile_data = profile.json() profile_data = profile.json()
return PROVIDER_HANDLERS["x"](token, profile_data) return PROVIDER_HANDLERS["x"](token, profile_data)
async def _fetch_vk_profile(client: Any, token: Any) -> dict: async def _fetch_vk_profile(client: Any, token: Any) -> dict:
"""Получает профиль из VK API""" """Получает профиль из VK API"""
profile = await client.get("users.get?fields=photo_400_orig,contacts&v=5.131", token=token) profile = await client.get("authors.get?fields=photo_400_orig,contacts&v=5.131", token=token)
profile_data = profile.json() profile_data = profile.json()
if profile_data.get("response"): if profile_data.get("response"):
user_data = profile_data["response"][0] user_data = profile_data["response"][0]

View File

@ -1,8 +1,19 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"files": { "files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"], "includes": [
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"] "**/*.tsx",
"**/*.ts",
"**/*.js",
"**/*.json",
"!dist",
"!node_modules",
"!**/.husky",
"!**/docs",
"!**/gen",
"!**/*.gen.ts",
"!**/*.d.ts"
]
}, },
"vcs": { "vcs": {
"enabled": true, "enabled": true,
@ -10,16 +21,13 @@
"useIgnoreFile": true, "useIgnoreFile": true,
"clientKind": "git" "clientKind": "git"
}, },
"organizeImports": { "assist": { "actions": { "source": { "organizeImports": "on" } } },
"enabled": true,
"ignore": ["./gen"]
},
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 2, "indentWidth": 2,
"lineWidth": 108, "lineWidth": 108,
"ignore": ["./src/graphql/schema", "./gen"] "includes": ["**", "!src/graphql/schema", "!gen", "!panel/graphql/generated"]
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {
@ -33,11 +41,11 @@
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"], "includes": ["**", "!**/*.scss", "!**/*.md", "!**/.DS_Store", "!**/*.svg", "!**/*.d.ts"],
"rules": { "rules": {
"all": true,
"complexity": { "complexity": {
"noForEach": "off", "noForEach": "off",
"noUselessFragments": "off",
"useOptionalChain": "warn", "useOptionalChain": "warn",
"useLiteralKeys": "off", "useLiteralKeys": "off",
"noExcessiveCognitiveComplexity": "off", "noExcessiveCognitiveComplexity": "off",
@ -46,10 +54,7 @@
"correctness": { "correctness": {
"useHookAtTopLevel": "off", "useHookAtTopLevel": "off",
"useImportExtensions": "off", "useImportExtensions": "off",
"noUndeclaredDependencies": "off", "noUndeclaredDependencies": "off"
"noNodejsModules": {
"level": "off"
}
}, },
"a11y": { "a11y": {
"useHeadingContent": "off", "useHeadingContent": "off",
@ -61,18 +66,16 @@
"useAltText": "off", "useAltText": "off",
"useButtonType": "off", "useButtonType": "off",
"noRedundantAlt": "off", "noRedundantAlt": "off",
"noStaticElementInteractions": "off",
"noSvgWithoutTitle": "off", "noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off" "noLabelWithoutControl": "off"
}, },
"nursery": {
"useImportRestrictions": "off"
},
"performance": { "performance": {
"noBarrelFile": "off" "noBarrelFile": "off",
"noNamespaceImport": "warn"
}, },
"style": { "style": {
"noNonNullAssertion": "off", "noNonNullAssertion": "off",
"noNamespaceImport": "warn",
"noUselessElse": "off", "noUselessElse": "off",
"useBlockStatements": "off", "useBlockStatements": "off",
"noImplicitBoolean": "off", "noImplicitBoolean": "off",
@ -81,12 +84,25 @@
"noDefaultExport": "off", "noDefaultExport": "off",
"useFilenamingConvention": "off", "useFilenamingConvention": "off",
"useExplicitLengthCheck": "off", "useExplicitLengthCheck": "off",
"useNodejsImportProtocol": "off" "noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error"
}, },
"suspicious": { "suspicious": {
"noConsole": "off", "noConsole": "off",
"noConsoleLog": "off", "noAssignInExpressions": "off",
"noAssignInExpressions": "off" "useAwait": "off",
"noEmptyBlockStatements": "off"
},
"nursery": {
"noFloatingPromises": "warn",
"noImportCycles": "warn"
} }
} }
} }

98
cache/cache.py vendored
View File

@ -462,11 +462,8 @@ async def cache_related_entities(shout: Shout) -> None:
""" """
Кэширует все связанные с публикацией сущности (авторов и темы) Кэширует все связанные с публикацией сущности (авторов и темы)
""" """
tasks = [] tasks = [cache_by_id(Author, author.id, cache_author) for author in shout.authors]
for author in shout.authors: tasks.extend(cache_by_id(Topic, topic.id, cache_topic) for topic in shout.topics)
tasks.append(cache_by_id(Author, author.id, cache_author))
for topic in shout.topics:
tasks.append(cache_by_id(Topic, topic.id, cache_topic))
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
@ -846,22 +843,85 @@ async def invalidate_author_cache(author_id: Union[int, str]) -> None:
async def clear_all_cache() -> None: async def clear_all_cache() -> None:
"""Очищает весь кеш (использовать осторожно)""" """
Очищает весь кэш Redis (используйте с осторожностью!)
Warning:
Эта функция удаляет ВСЕ данные из Redis!
Используйте только в тестовой среде или при критической необходимости.
"""
try: try:
# Get all cache keys await redis.execute("FLUSHDB")
topic_keys = await redis.keys("topic:*") logger.info("Весь кэш очищен")
author_keys = await redis.keys("author:*") except Exception as e:
search_keys = await redis.keys("search:*") logger.error(f"Ошибка при очистке кэша: {e}")
follows_keys = await redis.keys("follows:*")
all_keys = topic_keys + author_keys + search_keys + follows_keys
if all_keys: async def invalidate_topic_followers_cache(topic_id: int) -> None:
for key in all_keys: """
await redis.delete(key) Инвалидирует кеши подписчиков при удалении топика.
logger.info(f"Cleared {len(all_keys)} cache entries")
else: Эта функция:
logger.info("No cache entries to clear") 1. Получает список всех подписчиков топика
2. Инвалидирует персональные кеши подписок для каждого подписчика
3. Инвалидирует кеши самого топика
4. Логирует процесс для отладки
Args:
topic_id: ID топика для которого нужно инвалидировать кеши подписчиков
"""
try:
logger.debug(f"Инвалидация кешей подписчиков для топика {topic_id}")
# Получаем список всех подписчиков топика из БД
with local_session() as session:
followers_query = session.query(TopicFollower.follower).filter(TopicFollower.topic == topic_id)
follower_ids = [row[0] for row in followers_query.all()]
logger.debug(f"Найдено {len(follower_ids)} подписчиков топика {topic_id}")
# Инвалидируем кеши подписок для всех подписчиков
for follower_id in follower_ids:
cache_keys_to_delete = [
f"author:follows-topics:{follower_id}", # Список топиков на которые подписан автор
f"author:followers:{follower_id}", # Счетчик подписчиков автора
f"author:stat:{follower_id}", # Общая статистика автора
f"author:id:{follower_id}", # Кешированные данные автора
]
for cache_key in cache_keys_to_delete:
try:
await redis.execute("DEL", cache_key)
logger.debug(f"Удален кеш: {cache_key}")
except Exception as e:
logger.error(f"Ошибка при удалении кеша {cache_key}: {e}")
# Инвалидируем кеши самого топика
topic_cache_keys = [
f"topic:followers:{topic_id}", # Список подписчиков топика
f"topic:id:{topic_id}", # Данные топика по ID
f"topic:authors:{topic_id}", # Авторы топика
f"topic_shouts_{topic_id}", # Публикации топика (legacy format)
]
for cache_key in topic_cache_keys:
try:
await redis.execute("DEL", cache_key)
logger.debug(f"Удален кеш топика: {cache_key}")
except Exception as e:
logger.error(f"Ошибка при удалении кеша топика {cache_key}: {e}")
# Также ищем и удаляем коллекционные кеши, содержащие данные об этом топике
try:
collection_keys = await redis.execute("KEYS", "topics:stats:*")
if collection_keys:
await redis.execute("DEL", *collection_keys)
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
except Exception as e:
logger.error(f"Ошибка при удалении коллекционных кешей: {e}")
logger.info(f"Успешно инвалидированы кеши для топика {topic_id} и {len(follower_ids)} подписчиков")
except Exception as e: except Exception as e:
logger.error(f"Failed to clear cache: {e}") logger.error(f"Ошибка при инвалидации кешей подписчиков топика {topic_id}: {e}")
raise

38
codegen.ts Normal file
View 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

View File

@ -32,6 +32,14 @@ python dev.py
### Администрирование ### Администрирование
- **Админ-панель**: Управление пользователями, ролями, переменными среды - **Админ-панель**: Управление пользователями, ролями, переменными среды
- **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные) - **Управление публикациями**: Просмотр, поиск, фильтрация по статусу (опубликованные/черновики/удаленные)
- **Управление топиками**: Упрощенное редактирование топиков с иерархическим отображением
- **Клик по строке**: Модалка редактирования открывается при клике на строку таблицы
- **Ненавязчивый крестик**: Серая кнопка "×" для удаления, краснеет при hover
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом
- **Редактируемые поля**: ID (просмотр), название, slug, описание, сообщество, родители
- **Дерево топиков**: Визуализация родительско-дочерних связей с отступами и символами `└─`
- **Безопасное удаление**: Предупреждения о каскадном удалении дочерних топиков
- **Автообновление**: Рефреш списка после операций с корректной инвалидацией кешей
- **Просмотр данных**: Body, media, авторы, темы с удобной навигацией - **Просмотр данных**: Body, media, авторы, темы с удобной навигацией
- **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py - **DRY принцип**: Переиспользование существующих резолверов из reader.py и editor.py

View File

@ -1,3 +1,48 @@
## Админ-панель
- **Управление пользователями**: Просмотр, поиск, назначение ролей (user/moderator/admin)
- **Управление публикациями**: Таблица со всеми публикациями, фильтрация по статусу, превью контента
- **Управление топиками**: Полноценное редактирование топиков в админ-панели
- **Иерархическое отображение**: Темы показываются в виде дерева с отступами и символами `└─` для дочерних элементов
- **Колонки таблицы**: ID, название, slug, описание, сообщество, родители, действия
- **Простой интерфейс редактирования**:
- **Клик по строке**: Модалка редактирования открывается при клике на любом месте строки таблицы
- **Ненавязчивый крестик**: Кнопка удаления в виде серого "×", краснеет при hover
- **Простой HTML редактор**: Обычный contenteditable div с моноширинным шрифтом вместо сложного редактора
- **Редактируемые поля**:
- **ID**: Отображается для идентификации (поле только для чтения)
- **Название и slug**: Текстовые поля для основной информации
- **Описание**: Простой HTML редактор с placeholder
- **Картинка**: URL изображения топика
- **Сообщество**: ID сообщества с числовой валидацией
- **Родители**: Список parent_ids через запятую с автоматическим парсингом
- **Безопасное удаление**: Модальное окно подтверждения при клике на крестик
- **Корректная инвалидация кешей**: Автоматическое обновление счетчиков подписок у всех подписчиков
- **GraphQL интеграция**: Использование мутаций `UPDATE_TOPIC_MUTATION` и `DELETE_TOPIC_MUTATION`
- **Управление переменными среды**: Настройка конфигурации приложения
- **TypeScript интеграция**: Полная типизация с автогенерацией типов из GraphQL схемы
- **Responsive дизайн**: Адаптивность для разных размеров экранов
## Codegen интеграция
- **Автоматическая генерация типов**: TypeScript типы генерируются из GraphQL схемы
- **Файл конфигурации**: `codegen.ts` с настройками для client-side генерации
- **Структура проекта**: Разделение на queries, mutations и index файлы в `panel/graphql/generated/`
- **Type safety**: Строгая типизация для всех GraphQL операций в админ-панели
- **Developer Experience**: Автокомплит и проверка типов в IDE
## Улучшенная система кеширования топиков
- **Централизованная функция**: `invalidate_topic_followers_cache()` в модуле cache
- **Комплексная инвалидация**: Обработка кешей как самого топика, так и всех его подписчиков
- **Правильная последовательность**: Получение подписчиков ДО удаления данных из БД
- **Инвалидируемые кеши**:
- `author:follows-topics:{follower_id}` - список подписок на топики
- `author:followers:{follower_id}` - счетчики подписчиков
- `author:stat:{follower_id}` - общая статистика автора
- `topic:followers:{topic_id}` - список подписчиков топика
- **Архитектурные принципы**: Разделение ответственности, переиспользуемость, тестируемость
## Просмотры публикаций ## Просмотры публикаций
- Интеграция с Google Analytics для отслеживания просмотров публикаций - Интеграция с Google Analytics для отслеживания просмотров публикаций

View File

@ -42,7 +42,7 @@ Unfollow an entity.
### Queries ### Queries
#### get_shout_followers #### get_shout_followers
Get list of users who reacted to a shout. Get list of authors who reacted to a shout.
**Parameters:** **Parameters:**
- `slug: String` - Shout slug - `slug: String` - Shout slug

View File

@ -34,7 +34,7 @@ JWT_EXPIRATION_HOURS=24
-- Create oauth_links table -- Create oauth_links table
CREATE TABLE oauth_links ( CREATE TABLE oauth_links (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL, provider_id VARCHAR(255) NOT NULL,
provider_data JSONB, provider_data JSONB,

View File

@ -295,7 +295,7 @@ async def migrate_oauth_tokens():
refresh_token=author.provider_refresh_token refresh_token=author.provider_refresh_token
) )
print(f"Migrated OAuth tokens for {len(authors)} users") print(f"Migrated OAuth tokens for {len(authors)} authors")
``` ```
## Performance Benefits ## Performance Benefits

View File

@ -18,7 +18,7 @@ class CommunityRole(enum.Enum):
ADMIN = "admin" ADMIN = "admin"
@classmethod @classmethod
def as_string_array(cls, roles): def as_string_array(cls, roles) -> list[str]:
return [role.value for role in roles] return [role.value for role in roles]
@classmethod @classmethod
@ -59,6 +59,7 @@ class Community(BaseModel):
private = Column(Boolean, default=False) private = Column(Boolean, default=False)
followers = relationship("Author", secondary="community_follower") followers = relationship("Author", secondary="community_follower")
created_by_author = relationship("Author", foreign_keys=[created_by])
@hybrid_property @hybrid_property
def stat(self): def stat(self):

4501
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "admin-panel", "name": "publy-panel",
"version": "0.4.22", "version": "0.5.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -8,20 +8,33 @@
"serve": "vite preview", "serve": "vite preview",
"lint": "biome check . --fix", "lint": "biome check . --fix",
"format": "biome format . --write", "format": "biome format . --write",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^2.0.6",
"@types/node": "^22.15.0", "@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/client-preset": "^4.8.3",
"@graphql-codegen/typescript": "^4.0.6",
"@graphql-codegen/typescript-operations": "^4.2.0",
"@graphql-codegen/typescript-resolvers": "^4.0.6",
"@types/node": "^24.0.7",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"graphql": "^16.8.0", "graphql": "^16.11.0",
"solid-js": "^1.9.6", "graphql-tag": "^2.12.6",
"lightningcss": "^1.30.0",
"prismjs": "^1.30.0",
"solid-js": "^1.9.7",
"terser": "^5.39.0", "terser": "^5.39.0",
"typescript": "^5.8.0", "typescript": "^5.8.3",
"vite": "^6.3.0", "vite": "^7.0.0",
"vite-plugin-solid": "^2.11.0" "vite-plugin-solid": "^2.11.7"
},
"overrides": {
"vite": "^7.0.0"
}, },
"dependencies": { "dependencies": {
"prismjs": "^1.30.0" "@solidjs/router": "^0.15.3"
} }
} }

View File

@ -1,105 +1,89 @@
import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js' import { Route, Router } from '@solidjs/router'
import { isAuthenticated, getAuthTokenFromCookie } from './auth' import { lazy, onMount, Suspense } from 'solid-js'
import { AuthProvider, useAuth } from './context/auth'
// Ленивая загрузка компонентов // Ленивая загрузка компонентов
const AdminPage = lazy(() => import('./admin')) const AdminPage = lazy(() => {
const LoginPage = lazy(() => import('./login')) console.log('[App] Loading AdminPage component...')
return import('./admin')
})
const LoginPage = lazy(() => {
console.log('[App] Loading LoginPage component...')
return import('./routes/login')
})
/** /**
* Корневой компонент приложения с простой логикой отображения * Компонент защищенного маршрута
*/ */
const App: Component = () => { const ProtectedRoute = () => {
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null) console.log('[ProtectedRoute] Checking authentication...')
const [loading, setLoading] = createSignal(true) const auth = useAuth()
const [checkingAuth, setCheckingAuth] = createSignal(true) const authenticated = auth.isAuthenticated()
console.log(
`[ProtectedRoute] Authentication state: ${authenticated ? 'authenticated' : 'not authenticated'}`
)
// Проверяем авторизацию при монтировании if (!authenticated) {
onMount(() => { console.log('[ProtectedRoute] Not authenticated, redirecting to login...')
checkAuthentication() // Используем window.location.href для редиректа
}) window.location.href = '/login'
return (
// Периодическая проверка авторизации <div class="loading-screen">
createEffect(() => { <div class="loading-spinner" />
const authCheckInterval = setInterval(() => { <div>Проверка авторизации...</div>
// Перепроверяем статус авторизации каждые 60 секунд </div>
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)
} }
return ( return (
<div class="app-container">
<Suspense <Suspense
fallback={ fallback={
<div class="loading-screen"> <div class="loading-screen">
<div class="loading-spinner" /> <div class="loading-spinner" />
<h2>Загрузка компонентов...</h2> <div>Загрузка админ-панели...</div>
</div> </div>
} }
> >
<Show <AdminPage apiUrl={`${location.origin}/graphql`} />
when={!loading()} </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={ fallback={
<div class="loading-screen"> <div class="loading-screen">
<div class="loading-spinner" /> <div class="loading-spinner" />
<h2>Проверка авторизации...</h2> <div>Загрузка страницы входа...</div>
</div> </div>
} }
> >
{authenticated() ? ( <LoginPage />
<AdminPage apiUrl={`${location.origin}/graphql`} onLogout={handleLogout} />
) : (
<LoginPage onLoginSuccess={handleLoginSuccess} />
)}
</Show>
</Suspense> </Suspense>
)}
/>
<Route path="/" component={ProtectedRoute} />
<Route path="/admin" component={ProtectedRoute} />
<Route path="/admin/:tab" component={ProtectedRoute} />
</Router>
</div> </div>
</AuthProvider>
) )
} }

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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
View 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
}
}

View File

@ -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
View 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)
}

View 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
View 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 || ''

View File

@ -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

View 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
View 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

View 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

View 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
View 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

View 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
View 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
View 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
View 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
View 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

View File

@ -1,44 +1,73 @@
/** /**
* Основные стили приложения * Global Styles and CSS Variables
* Minimal global styling with focus on CSS variables and reset
*/ */
/* Сброс стилей */ /* Global Styles */
@import './styles/GlobalVariables.module.css';
/* CSS Reset and Base Styles */
* { * {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body, html {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.6;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Minimal Accessibility and Utility Styles */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
border: 0;
padding: 0;
clip: rect(0 0 0 0);
overflow: hidden;
}
*:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Responsive Typography */
@media (max-width: 768px) {
:root {
font-size: 14px;
}
}
/* Print Styles */
@media print {
body {
background: none;
color: #000;
}
} }
/* Общие стили */ /* Общие стили */
:root { :root {
/* Основные цвета */
--primary-color: #000000;
--primary-dark: #333333;
--primary-light: #F5F5F5;
/* Статусные цвета */
--success-color: #155724;
--success-light: #d4edda;
--success-border: #c3e6cb;
--danger-color: #721c24;
--danger-light: #f8d7da;
--danger-border: #f5c6cb;
--warning-color: #856404;
--warning-light: #fff3cd;
--warning-border: #ffeaa7;
/* Текст и фон */
--text-color: #000000;
--text-secondary: #666666;
--text-muted: #6b7280;
--bg-color: #FFFFFF;
--card-bg: #FFFFFF;
/* Границы и тени */ /* Границы и тени */
--border-color: #E0E0E0; --border-color: #E0E0E0;
--border-radius-sm: 4px; --border-radius-sm: 4px;
@ -52,11 +81,10 @@
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace; --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
/* Размеры */ /* Размеры */
--container-max-width: 1200px; --container-max-width: 1400px;
--header-height: 60px; --header-height: 60px;
/* Анимации */ /* Анимации */
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease; --transition-normal: 0.3s ease;
/* Z-индексы */ /* Z-индексы */
@ -83,29 +111,34 @@ body {
} }
/* Общие элементы интерфейса */ /* Общие элементы интерфейса */
.loading-screen, .loading { .loading-screen {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 200px; min-height: 100vh;
padding: 20px; background-color: var(--background-color);
text-align: center; color: var(--text-color-light);
color: var(--primary-color); font-size: var(--font-size-lg);
gap: 1rem;
} }
.loading-spinner { .loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1); width: 2rem;
border-left-color: var(--primary-color); height: 2rem;
border: 3px solid var(--border-color);
border-radius: 50%; border-radius: 50%;
width: 40px; border-top-color: var(--primary-color);
height: 40px;
margin-bottom: 20px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
.error-message { .error-message {
@ -168,34 +201,27 @@ body {
} }
button { button {
background-color: var(--primary-color);
color: white;
border: none; border: none;
border-radius: var(--border-radius-md); background: none;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: var(--transition-fast); padding: 0;
width: 100%; margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
} }
button:hover { button:focus,
background-color: var(--primary-dark); input:focus,
transform: translateY(-1px); select:focus,
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); textarea:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
} }
button:disabled { button:disabled,
background-color: #E5E9F2; input:disabled,
color: #A0AEC0; select:disabled,
textarea:disabled {
opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none;
box-shadow: none;
} }
/* Стили для страницы входа */ /* Стили для страницы входа */
@ -329,7 +355,7 @@ header h1 {
} }
main { main {
padding: 20px; padding: 1.5rem 3rem;
max-width: var(--container-max-width); max-width: var(--container-max-width);
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
@ -337,7 +363,7 @@ main {
} }
/* Таблица пользователей */ /* Таблица пользователей */
.users-list { .authors-list {
overflow-x: auto; overflow-x: auto;
margin-top: 1rem; margin-top: 1rem;
} }
@ -351,6 +377,7 @@ table {
overflow: hidden; overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
background-color: var(--card-bg); background-color: var(--card-bg);
min-width: 900px;
} }
thead { thead {
@ -358,10 +385,11 @@ thead {
} }
th, td { th, td {
padding: 14px 16px; padding: 18px 20px;
text-align: left; text-align: left;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 14px; font-size: 15px;
line-height: 1.5;
} }
th { th {
@ -369,7 +397,7 @@ th {
color: var(--text-secondary); color: var(--text-secondary);
background-color: #F5F7FA; background-color: #F5F7FA;
text-transform: uppercase; text-transform: uppercase;
font-size: 12px; font-size: 13px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@ -710,12 +738,12 @@ tr:hover {
} }
/* Поиск */ /* Поиск */
.users-controls { .authors-controls {
margin-bottom: 16px; margin-bottom: 16px;
width: 100%;
} }
.search-container { .search-container {
max-width: 500px;
width: 100%; width: 100%;
} }
@ -771,7 +799,7 @@ tr:hover {
flex-direction: column; flex-direction: column;
} }
.users-list { .authors-list {
font-size: 14px; font-size: 14px;
} }
@ -1117,7 +1145,7 @@ th.sortable.sorted .sort-icon {
padding: 8px 5px; padding: 8px 5px;
} }
.users-list, .authors-list,
.shouts-list table { .shouts-list table {
font-size: 12px; font-size: 12px;
} }
@ -1385,99 +1413,7 @@ button:hover,
} }
/* Оптимизация для доступности */ /* Оптимизация для доступности */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.focus-visible:focus-visible { .focus-visible:focus-visible {
outline: 2px solid var(--primary-color); outline: 2px solid var(--primary-color);
outline-offset: 2px; outline-offset: 2px;
} }
/* Убираем скругления и делаем строгий стиль для пагинации и кнопок */
button,
.pagination,
.pagination-button,
.pagination-per-page select {
border-radius: 0 !important;
box-shadow: none !important;
}
.pagination {
background: #ededed;
border: 1px solid var(--border-color);
box-shadow: none;
}
.pagination-button {
min-width: 44px;
height: 44px;
padding: 0;
background: #181818;
color: #fff;
border: 1px solid #222;
font-size: 18px;
font-weight: 500;
border-radius: 0 !important;
box-shadow: none !important;
margin-bottom: 8px;
transition: background 0.15s, color 0.15s, border 0.15s;
}
.pagination-button.active {
background: #fff;
color: #111;
border: 2px solid #fff;
font-weight: 700;
}
.pagination-button:hover:not(:disabled) {
background: #333;
color: #fff;
border-color: #111;
transform: none;
box-shadow: none;
}
.pagination-button:disabled {
background: #aaa;
color: #fff;
opacity: 0.5;
border-color: #888;
}
.pagination-ellipsis {
background: transparent;
color: #888;
min-width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: none;
margin-bottom: 8px;
}
.pagination-per-page select {
background: #181818;
color: #fff;
border: 1px solid #222;
border-radius: 0 !important;
box-shadow: none !important;
}
.modal-actions {
display: flex;
gap: 16px;
justify-content: flex-end;
padding: 0;
margin-top: 12px;
}

View 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;
}
}

View 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);
}
}

View 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;
}

View 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;
}
}

View 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;
}

View File

View 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;
}
}

View 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;
}

View 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;
}
}

View 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);
}

View 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
View 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
View 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
View 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
View 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 }

View 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
View 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
View 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
View 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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/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
View 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
View 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 'лет'
}

View File

@ -16,4 +16,4 @@
"reportUnknownArgumentType": false, "reportUnknownArgumentType": false,
"reportPrivateUsage": false, "reportPrivateUsage": false,
"reportUntypedFunctionDecorator": false "reportUntypedFunctionDecorator": false
} }

View File

@ -62,11 +62,11 @@ async def admin_get_users(
current_page = (offset // per_page) + 1 if per_page > 0 else 1 current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем пагинацию # Применяем пагинацию
users = query.order_by(Author.id).offset(offset).limit(limit).all() authors = query.order_by(Author.id).offset(offset).limit(limit).all()
# Преобразуем в формат для API # Преобразуем в формат для API
return { return {
"users": [ "authors": [
{ {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
@ -76,7 +76,7 @@ async def admin_get_users(
"created_at": user.created_at, "created_at": user.created_at,
"last_seen": user.last_seen, "last_seen": user.last_seen,
} }
for user in users for user in authors
], ],
"total": total_count, "total": total_count,
"page": current_page, "page": current_page,
@ -247,11 +247,11 @@ async def update_env_variables(_: None, info: GraphQLResolveInfo, variables: lis
@admin_auth_required @admin_auth_required
async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, Any]) -> dict[str, Any]: async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, Any]) -> dict[str, Any]:
""" """
Обновляет роли пользователя Обновляет данные пользователя (роли, email, имя, slug)
Args: Args:
info: Контекст GraphQL запроса info: Контекст GraphQL запроса
user: Данные для обновления пользователя (содержит id и roles) user: Данные для обновления пользователя
Returns: Returns:
Boolean: результат операции или объект с ошибкой Boolean: результат операции или объект с ошибкой
@ -259,6 +259,9 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
try: try:
user_id = user.get("id") user_id = user.get("id")
roles = user.get("roles", []) roles = user.get("roles", [])
email = user.get("email")
name = user.get("name")
slug = user.get("slug")
if not roles: if not roles:
logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.") logger.warning(f"Пользователю {user_id} не назначено ни одной роли. Доступ в систему будет заблокирован.")
@ -272,6 +275,28 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
logger.error(error_msg) logger.error(error_msg)
return {"success": False, "error": error_msg} return {"success": False, "error": error_msg}
# Обновляем основные поля профиля
profile_updated = False
if email is not None and email != author.email:
# Проверяем уникальность email
existing_author = session.query(Author).filter(Author.email == email, Author.id != user_id).first()
if existing_author:
return {"success": False, "error": f"Email {email} уже используется другим пользователем"}
author.email = email
profile_updated = True
if name is not None and name != author.name:
author.name = name
profile_updated = True
if slug is not None and slug != author.slug:
# Проверяем уникальность slug
existing_author = session.query(Author).filter(Author.slug == slug, Author.id != user_id).first()
if existing_author:
return {"success": False, "error": f"Slug {slug} уже используется другим пользователем"}
author.slug = slug
profile_updated = True
# Получаем ID сообщества по умолчанию # Получаем ID сообщества по умолчанию
default_community_id = 1 # Используем значение по умолчанию из модели AuthorRole default_community_id = 1 # Используем значение по умолчанию из модели AuthorRole
@ -307,19 +332,25 @@ async def admin_update_user(_: None, info: GraphQLResolveInfo, user: dict[str, A
f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен." f"Пользователю {author.email or author.id} не назначена роль 'reader'. Доступ в систему будет ограничен."
) )
logger.info(f"Роли пользователя {author.email or author.id} обновлены: {', '.join(found_role_ids)}") update_details = []
if profile_updated:
update_details.append("профиль")
if roles:
update_details.append(f"роли: {', '.join(found_role_ids)}")
logger.info(f"Данные пользователя {author.email or author.id} обновлены: {', '.join(update_details)}")
return {"success": True} return {"success": True}
except Exception as e: except Exception as e:
# Обработка вложенных исключений # Обработка вложенных исключений
session.rollback() session.rollback()
error_msg = f"Ошибка при изменении ролей: {e!s}" error_msg = f"Ошибка при изменении данных пользователя: {e!s}"
logger.error(error_msg) logger.error(error_msg)
return {"success": False, "error": error_msg} return {"success": False, "error": error_msg}
except Exception as e: except Exception as e:
import traceback import traceback
error_msg = f"Ошибка при обновлении ролей пользователя: {e!s}" error_msg = f"Ошибка при обновлении данных пользователя: {e!s}"
logger.error(error_msg) logger.error(error_msg)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "error": error_msg} return {"success": False, "error": error_msg}

View File

@ -2,15 +2,49 @@ from typing import Any
from graphql import GraphQLResolveInfo from graphql import GraphQLResolveInfo
from auth.decorators import editor_or_admin_required
from auth.orm import Author from auth.orm import Author
from orm.community import Community, CommunityFollower from orm.community import Community, CommunityFollower
from services.db import local_session from services.db import local_session
from services.schema import mutation, query from services.schema import mutation, query, type_community
@query.field("get_communities_all") @query.field("get_communities_all")
async def get_communities_all(_: None, _info: GraphQLResolveInfo) -> list[Community]: async def get_communities_all(_: None, _info: GraphQLResolveInfo) -> list[Community]:
return local_session().query(Community).all() from sqlalchemy.orm import joinedload
with local_session() as session:
# Загружаем сообщества с проверкой существования авторов
communities = (
session.query(Community)
.options(joinedload(Community.created_by_author))
.join(
Author,
Community.created_by == Author.id, # INNER JOIN - исключает сообщества без авторов
)
.filter(
Community.created_by.isnot(None), # Дополнительная проверка
Author.id.isnot(None), # Проверяем что автор существует
)
.all()
)
# Дополнительная проверка валидности данных
valid_communities = []
for community in communities:
if (
community.created_by
and hasattr(community, "created_by_author")
and community.created_by_author
and community.created_by_author.id
):
valid_communities.append(community)
else:
from utils.logger import root_logger as logger
logger.warning(f"Исключено сообщество {community.id} ({community.slug}) - проблемы с автором")
return valid_communities
@query.field("get_community") @query.field("get_community")
@ -63,41 +97,192 @@ async def leave_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[
@mutation.field("create_community") @mutation.field("create_community")
async def create_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]: @editor_or_admin_required
author_dict = info.context.get("author", {}) async def create_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
author_id = author_dict.get("id") # Получаем author_id из контекста через декоратор авторизации
request = info.context.get("request")
author_id = None
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
return {"error": "Не удалось определить автора"}
try:
with local_session() as session: with local_session() as session:
session.add(Community(author=author_id, **community_data)) # Исключаем created_by из входных данных - он всегда из токена
filtered_input = {k: v for k, v in community_input.items() if k != "created_by"}
# Создаем новое сообщество с обязательным created_by из токена
new_community = Community(created_by=author_id, **filtered_input)
session.add(new_community)
session.commit() session.commit()
return {"ok": True} return {"error": None}
except Exception as e:
return {"error": f"Ошибка создания сообщества: {e!s}"}
@mutation.field("update_community") @mutation.field("update_community")
async def update_community(_: None, info: GraphQLResolveInfo, community_data: dict[str, Any]) -> dict[str, Any]: @editor_or_admin_required
author_dict = info.context.get("author", {}) async def update_community(_: None, info: GraphQLResolveInfo, community_input: dict[str, Any]) -> dict[str, Any]:
author_id = author_dict.get("id") # Получаем author_id из контекста через декоратор авторизации
slug = community_data.get("slug") request = info.context.get("request")
if slug: author_id = None
with local_session() as session:
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
return {"error": "Не удалось определить автора"}
slug = community_input.get("slug")
if not slug:
return {"error": "Не указан slug сообщества"}
try: try:
session.query(Community).where(Community.created_by == author_id, Community.slug == slug).update( with local_session() as session:
community_data # Находим сообщество для обновления
) community = session.query(Community).filter(Community.slug == slug).first()
if not community:
return {"error": "Сообщество не найдено"}
# Проверяем права на редактирование (создатель или админ/редактор)
with local_session() as auth_session:
author = auth_session.query(Author).filter(Author.id == author_id).first()
user_roles = [role.id for role in author.roles] if author and author.roles else []
# Разрешаем редактирование если пользователь - создатель или имеет роль admin/editor
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для редактирования этого сообщества"}
# Обновляем поля сообщества
for key, value in community_input.items():
# Исключаем изменение created_by - создатель не может быть изменен
if hasattr(community, key) and key not in ["slug", "created_by"]:
setattr(community, key, value)
session.commit() session.commit()
return {"error": None}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"error": f"Ошибка обновления сообщества: {e!s}"}
return {"ok": True}
return {"ok": False, "error": "Please, set community slug in input"}
@mutation.field("delete_community") @mutation.field("delete_community")
@editor_or_admin_required
async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]: async def delete_community(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
author_dict = info.context.get("author", {}) # Получаем author_id из контекста через декоратор авторизации
author_id = author_dict.get("id") request = info.context.get("request")
with local_session() as session: author_id = None
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
return {"error": "Не удалось определить автора"}
try: try:
session.query(Community).where(Community.slug == slug, Community.created_by == author_id).delete() with local_session() as session:
# Находим сообщество для удаления
community = session.query(Community).filter(Community.slug == slug).first()
if not community:
return {"error": "Сообщество не найдено"}
# Проверяем права на удаление (создатель или админ/редактор)
with local_session() as auth_session:
author = auth_session.query(Author).filter(Author.id == author_id).first()
user_roles = [role.id for role in author.roles] if author and author.roles else []
# Разрешаем удаление если пользователь - создатель или имеет роль admin/editor
if community.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для удаления этого сообщества"}
# Удаляем сообщество
session.delete(community)
session.commit() session.commit()
return {"ok": True} return {"error": None}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"error": f"Ошибка удаления сообщества: {e!s}"}
@type_community.field("created_by")
def resolve_community_created_by(obj: Community, *_: Any) -> Author:
"""
Резолвер поля created_by для Community.
Возвращает автора, создавшего сообщество.
"""
# Если связь уже загружена через joinedload и валидна
if hasattr(obj, "created_by_author") and obj.created_by_author and obj.created_by_author.id:
return obj.created_by_author
# Критическая ошибка - это не должно происходить после фильтрации в get_communities_all
from utils.logger import root_logger as logger
logger.error(f"КРИТИЧЕСКАЯ ОШИБКА: Резолвер created_by вызван для сообщества {obj.id} без валидного автора")
error_message = f"Сообщество {obj.id} не имеет валидного создателя"
raise ValueError(error_message)
@type_community.field("stat")
def resolve_community_stat(obj: Community, *_: Any) -> dict[str, int]:
"""
Резолвер поля stat для Community.
Возвращает статистику сообщества: количество публикаций, подписчиков и авторов.
"""
from sqlalchemy import distinct, func
from orm.shout import Shout, ShoutAuthor
try:
with local_session() as session:
# Количество опубликованных публикаций в сообществе
shouts_count = (
session.query(func.count(Shout.id))
.filter(Shout.community == obj.id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.scalar()
or 0
)
# Количество подписчиков сообщества
followers_count = (
session.query(func.count(CommunityFollower.follower))
.filter(CommunityFollower.community == obj.id)
.scalar()
or 0
)
# Количество уникальных авторов, опубликовавших в сообществе
authors_count = (
session.query(func.count(distinct(ShoutAuthor.author)))
.join(Shout, ShoutAuthor.shout == Shout.id)
.filter(Shout.community == obj.id, Shout.published_at.is_not(None), Shout.deleted_at.is_(None))
.scalar()
or 0
)
return {"shouts": int(shouts_count), "followers": int(followers_count), "authors": int(authors_count)}
except Exception as e:
from utils.logger import root_logger as logger
logger.error(f"Ошибка при получении статистики сообщества {obj.id}: {e}")
# Возвращаем нулевую статистику при ошибке
return {"shouts": 0, "followers": 0, "authors": 0}

View File

@ -11,6 +11,7 @@ from cache.cache import (
get_cached_topic_by_slug, get_cached_topic_by_slug,
get_cached_topic_followers, get_cached_topic_followers,
invalidate_cache_by_prefix, invalidate_cache_by_prefix,
invalidate_topic_followers_cache,
) )
from orm.reaction import Reaction, ReactionKind from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic
@ -446,3 +447,55 @@ async def get_topic_authors(_: None, _info: GraphQLResolveInfo, slug: str) -> li
topic = await get_cached_topic_by_slug(slug, get_with_stat) topic = await get_cached_topic_by_slug(slug, get_with_stat)
topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None topic_id = getattr(topic, "id", None) if isinstance(topic, Topic) else topic.get("id") if topic else None
return await get_cached_topic_authors(topic_id) if topic_id else [] return await get_cached_topic_authors(topic_id) if topic_id else []
# Мутация для удаления темы по ID (для админ-панели)
@mutation.field("delete_topic_by_id")
@login_required
async def delete_topic_by_id(_: None, info: GraphQLResolveInfo, topic_id: int) -> dict[str, Any]:
"""
Удаляет тему по ID. Используется в админ-панели.
Args:
topic_id: ID темы для удаления
Returns:
dict: Результат операции
"""
viewer_id = info.context.get("author", {}).get("id")
with local_session() as session:
topic = session.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
return {"success": False, "message": "Топик не найден"}
author = session.query(Author).filter(Author.id == viewer_id).first()
if not author:
return {"success": False, "message": "Не авторизован"}
# TODO: проверить права администратора
# Для админ-панели допускаем удаление любых топиков администратором
try:
# Инвалидируем кеши подписчиков ПЕРЕД удалением данных из БД
await invalidate_topic_followers_cache(topic_id)
# Удаляем связанные данные (подписчики, связи с публикациями)
session.query(TopicFollower).filter(TopicFollower.topic == topic_id).delete()
session.query(ShoutTopic).filter(ShoutTopic.topic == topic_id).delete()
# Удаляем сам топик
session.delete(topic)
session.commit()
# Инвалидируем основные кеши топика
await invalidate_topics_cache(topic_id)
if topic.slug:
await redis.execute("DEL", f"topic:slug:{topic.slug}")
logger.info(f"Топик {topic_id} успешно удален")
return {"success": True, "message": "Топик успешно удален"}
except Exception as e:
session.rollback()
logger.error(f"Ошибка при удалении топика {topic_id}: {e}")
return {"success": False, "message": f"Ошибка при удалении: {e!s}"}

View File

@ -31,6 +31,9 @@ type AdminUserInfo {
input AdminUserUpdateInput { input AdminUserUpdateInput {
id: Int! id: Int!
email: String
name: String
slug: String
roles: [String!] roles: [String!]
community: Int community: Int
} }
@ -43,7 +46,7 @@ type Role {
# Тип для пагинированного ответа пользователей # Тип для пагинированного ответа пользователей
type AdminUserListResponse { type AdminUserListResponse {
users: [AdminUserInfo!]! authors: [AdminUserInfo!]!
total: Int! total: Int!
page: Int! page: Int!
perPage: Int! perPage: Int!

View File

@ -21,6 +21,8 @@ input TopicInput {
title: String title: String
body: String body: String
pic: String pic: String
community: Int
parent_ids: [Int]
} }
input DraftInput { input DraftInput {

View File

@ -36,6 +36,7 @@ type Mutation {
create_topic(topic_input: TopicInput!): CommonResult! create_topic(topic_input: TopicInput!): CommonResult!
update_topic(topic_input: TopicInput!): CommonResult! update_topic(topic_input: TopicInput!): CommonResult!
delete_topic(slug: String!): CommonResult! delete_topic(slug: String!): CommonResult!
delete_topic_by_id(id: Int!): CommonResult!
# reaction # reaction
create_reaction(reaction: ReactionInput!): CommonResult! create_reaction(reaction: ReactionInput!): CommonResult!

View File

@ -66,7 +66,7 @@ type Query {
# topic # topic
get_topic(slug: String!): Topic get_topic(slug: String!): Topic
get_topics_all: [Topic] get_topics_all: [Topic]!
get_topics_by_author(slug: String, user: String, author_id: Int): [Topic] get_topics_by_author(slug: String, user: String, author_id: Int): [Topic]
get_topics_by_community(community_id: Int!, limit: Int, offset: Int): [Topic] get_topics_by_community(community_id: Int!, limit: Int, offset: Int): [Topic]

View File

@ -189,6 +189,8 @@ type Topic {
title: String title: String
body: String body: String
pic: String pic: String
community: Int
parent_ids: [Int]
stat: TopicStat stat: TopicStat
oid: String oid: String
is_main: Boolean is_main: Boolean

View File

@ -8,7 +8,8 @@ from services.db import create_table_if_not_exists, local_session
query = QueryType() query = QueryType()
mutation = MutationType() mutation = MutationType()
type_draft = ObjectType("Draft") type_draft = ObjectType("Draft")
resolvers: List[SchemaBindable] = [query, mutation, type_draft] type_community = ObjectType("Community")
resolvers: List[SchemaBindable] = [query, mutation, type_draft, type_community]
def create_all_tables() -> None: def create_all_tables() -> None:

View File

@ -17,7 +17,9 @@
"lib": ["DOM", "ESNext"], "lib": ["DOM", "ESNext"],
"paths": { "paths": {
"~/*": ["./panel/*"] "~/*": ["./panel/*"]
}
}, },
"exclude": [] "typeRoots": ["./panel/types", "./node_modules/@types"]
},
"include": ["panel/**/*.ts", "panel/**/*.tsx", "panel/**/*.d.ts"],
"exclude": ["node_modules"]
} }

View File

@ -1,4 +1,4 @@
import { resolve } from 'path' import { resolve } from 'node:path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import solidPlugin from 'vite-plugin-solid' import solidPlugin from 'vite-plugin-solid'
@ -7,11 +7,15 @@ const isProd = process.env.NODE_ENV === 'production'
export default defineConfig({ export default defineConfig({
plugins: [solidPlugin()], plugins: [solidPlugin()],
build: { build: {
target: 'esnext', target: 'esnext',
outDir: 'dist', outDir: 'dist',
minify: isProd, assetsDir: 'assets',
emptyOutDir: true,
sourcemap: !isProd, sourcemap: !isProd,
minify: isProd ? 'terser' : false,
cssMinify: isProd ? 'lightningcss' : false,
// Оптимизация сборки // Оптимизация сборки
cssCodeSplit: true, cssCodeSplit: true,
@ -36,7 +40,7 @@ export default defineConfig({
// Оптимизация зависимостей // Оптимизация зависимостей
optimizeDeps: { optimizeDeps: {
include: ['solid-js', '@solidjs/router'], include: ['solid-js'],
exclude: [] exclude: []
}, },