Squashed new RBAC
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
2025-07-02 22:30:21 +03:00
parent 7585dae0ab
commit 82111ed0f6
100 changed files with 14785 additions and 5888 deletions

203
panel/routes/admin.tsx Normal file
View File

@@ -0,0 +1,203 @@
/**
* Компонент страницы администратора
* @module AdminPage
*/
import { useNavigate, useParams } from '@solidjs/router'
import { Component, createEffect, createSignal, onMount, Show } from 'solid-js'
import publyLogo from '../assets/publy.svg?url'
import { logout } from '../context/auth'
import styles from '../styles/Admin.module.css'
import Button from '../ui/Button'
import CommunitySelector from '../ui/CommunitySelector'
import LanguageSwitcher from '../ui/LanguageSwitcher'
// Прямой импорт компонентов вместо ленивой загрузки
import AuthorsRoute from './authors'
import CollectionsRoute from './collections'
import CommunitiesRoute from './communities'
import EnvRoute from './env'
import InvitesRoute from './invites'
import ShoutsRoute from './shouts'
import { Topics as TopicsRoute } from './topics'
/**
* Интерфейс свойств компонента AdminPage
*/
export interface AdminPageProps {
apiUrl: string
onLogout?: () => void
}
/**
* Компонент страницы администратора
*/
const AdminPage: Component<AdminPageProps> = (props) => {
console.log('[AdminPage] Initializing...')
const navigate = useNavigate()
const params = useParams()
const [error, setError] = createSignal<string | null>(null)
const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
const [currentTab, setCurrentTab] = createSignal<string>('authors')
onMount(() => {
console.log('[AdminPage] Component mounted')
console.log('[AdminPage] Initial params:', params)
// Если мы на корневом пути /admin, редиректим на /admin/authors
if (!params.tab) {
navigate('/admin/authors', { replace: true })
} else {
setCurrentTab(params.tab)
}
})
// Отслеживаем изменения параметров роута
createEffect(() => {
console.log('[AdminPage] Params changed:', params)
console.log('[AdminPage] Current tab param:', params.tab)
const newTab = params.tab || 'authors'
setCurrentTab(newTab)
console.log('[AdminPage] Updated currentTab to:', newTab)
})
/**
* Обрабатывает выход из системы
*/
const handleLogout = async () => {
try {
await logout()
if (props.onLogout) {
props.onLogout()
}
navigate('/login')
} catch (error) {
setError(`Ошибка при выходе: ${(error as Error).message}`)
}
}
/**
* Обработчик ошибок
*/
const handleError = (error: string) => {
setError(error)
// Скрываем ошибку через 5 секунд
setTimeout(() => setError(null), 5000)
}
/**
* Обработчик успешных операций
*/
const handleSuccess = (message: string) => {
setSuccessMessage(message)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
}
return (
<div class={styles['admin-panel']}>
<header>
<div class={styles['header-container']}>
<div class={styles['header-left']}>
<img src={publyLogo} alt="Logo" class={styles.logo} />
<h1>
Панель администратора
<span class={styles['version-badge']}>v{__APP_VERSION__}</span>
</h1>
</div>
<div class={styles['header-right']}>
<CommunitySelector />
<LanguageSwitcher />
<button class={styles['logout-button']} onClick={handleLogout}>
Выйти
</button>
</div>
</div>
<nav class={styles['admin-tabs']}>
<Button
variant={currentTab() === 'authors' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/authors')}
>
Авторы
</Button>
<Button
variant={currentTab() === 'shouts' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/shouts')}
>
Публикации
</Button>
<Button
variant={currentTab() === 'topics' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/topics')}
>
Темы
</Button>
<Button
variant={currentTab() === 'communities' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/communities')}
>
Сообщества
</Button>
<Button
variant={currentTab() === 'collections' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/collections')}
>
Коллекции
</Button>
<Button
variant={currentTab() === 'invites' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/invites')}
>
Приглашения
</Button>
<Button
variant={currentTab() === 'env' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/env')}
>
Переменные среды
</Button>
</nav>
</header>
<main>
<Show when={error()}>
<div class={styles['error-message']}>{error()}</div>
</Show>
<Show when={successMessage()}>
<div class={styles['success-message']}>{successMessage()}</div>
</Show>
{/* Используем Show компоненты для каждой вкладки */}
<Show when={currentTab() === 'authors'}>
<AuthorsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'shouts'}>
<ShoutsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'topics'}>
<TopicsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'communities'}>
<CommunitiesRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'collections'}>
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'invites'}>
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={currentTab() === 'env'}>
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
</main>
</div>
)
}
export default AdminPage

View File

@@ -1,4 +1,6 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import type { AuthorsSortField } from '../context/sort'
import { AUTHORS_SORT_CONFIG } from '../context/sortConfig'
import { query } from '../graphql'
import type { Query, AdminUserInfo as User } from '../graphql/generated/schema'
import { ADMIN_UPDATE_USER_MUTATION } from '../graphql/mutations'
@@ -6,6 +8,8 @@ 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 SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
import { formatDateRelative } from '../utils/date'
export interface AuthorsRouteProps {
@@ -28,7 +32,7 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
totalPages: number
}>({
page: 1,
limit: 10,
limit: 20,
total: 0,
totalPages: 1
})
@@ -63,7 +67,7 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
}
} catch (error) {
console.error('[AuthorsRoute] Failed to load authors:', error)
props.onError?.(error instanceof Error ? error.message : 'Failed to load authors')
props.onError?.(error instanceof Error ? error.message : 'Не удалось загрузить список пользователей')
} finally {
setLoading(false)
}
@@ -131,9 +135,8 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
}
// Search handlers
function handleSearchChange(e: Event) {
const input = e.target as HTMLInputElement
setSearchQuery(input.value)
function handleSearchChange(value: string) {
setSearchQuery(value)
}
function handleSearch() {
@@ -141,13 +144,6 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
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...')
@@ -155,34 +151,40 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
})
/**
* Компонент для отображения роли с иконкой
* Компонент для отображения роли с эмоджи и тултипом
*/
const RoleBadge: Component<{ role: string }> = (props) => {
const getRoleIcon = (role: string): string => {
switch (role.toLowerCase()) {
switch (role.toLowerCase().trim()) {
case 'администратор':
case 'admin':
return '👑'
return '🪄'
case 'редактор':
case 'editor':
return ''
return ''
case 'эксперт':
case 'expert':
return '🎓'
return '🔬'
case 'автор':
case 'author':
return '📝'
case 'читатель':
case 'reader':
return '👤'
return '📖'
case 'banned':
case 'заблокирован':
return '🚫'
case 'verified':
case 'проверен':
return '✓'
default:
return '👤'
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 title={props.role} style={{ 'margin-right': '0.25rem' }}>
{getRoleIcon(props.role)}
</span>
)
}
@@ -198,57 +200,67 @@ const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
</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>
<TableControls
searchValue={searchQuery()}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
searchPlaceholder="Поиск по email, имени или ID..."
isLoading={loading()}
/>
<div class={styles['authors-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Имя</th>
<th>Создан</th>
<SortableHeader
field={'id' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
ID
</SortableHeader>
<SortableHeader
field={'email' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
Email
</SortableHeader>
<SortableHeader
field={'name' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
Имя
</SortableHeader>
<SortableHeader
field={'created_at' as AuthorsSortField}
allowedFields={AUTHORS_SORT_CONFIG.allowedFields}
>
Создан
</SortableHeader>
<th>Роли</th>
</tr>
</thead>
<tbody>
<For each={authors()}>
{(user) => (
<tr>
<tr
onClick={() => {
setSelectedUser(user)
setShowEditModal(true)
}}
>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{formatDateRelative(user.created_at || Date.now())}</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>
{/* Показываем сообщение если ролей нет */}
{(!user.roles || user.roles.length === 0) && (
<span style="color: #999; font-size: 0.875rem;">Нет ролей</span>
)}
</div>
</td>
</tr>

View File

@@ -9,6 +9,7 @@ import CollectionEditModal from '../modals/CollectionEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import TableControls from '../ui/TableControls'
/**
* Интерфейс для коллекции
@@ -39,12 +40,20 @@ interface CollectionsRouteProps {
*/
const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
const [collections, setCollections] = createSignal<Collection[]>([])
const [filteredCollections, setFilteredCollections] = createSignal<Collection[]>([])
const [loading, setLoading] = createSignal(false)
const [editModal, setEditModal] = createSignal<{ show: boolean; collection: Collection | null }>({
const [searchQuery, setSearchQuery] = createSignal('')
const [editModal, setEditModal] = createSignal<{
show: boolean
collection: Collection | null
}>({
show: false,
collection: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; collection: Collection | null }>({
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
collection: Collection | null
}>({
show: false,
collection: null
})
@@ -72,7 +81,9 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
throw new Error(result.errors[0].message)
}
setCollections(result.data.get_collections_all || [])
const allCollections = result.data.get_collections_all || []
setCollections(allCollections)
filterCollections(allCollections, searchQuery())
} catch (error) {
props.onError(`Ошибка загрузки коллекций: ${(error as Error).message}`)
} finally {
@@ -80,6 +91,42 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
}
}
/**
* Фильтрует коллекции по поисковому запросу
*/
const filterCollections = (allCollections: Collection[], query: string) => {
if (!query) {
setFilteredCollections(allCollections)
return
}
const lowerQuery = query.toLowerCase()
const filtered = allCollections.filter(
(collection) =>
collection.title.toLowerCase().includes(lowerQuery) ||
collection.slug.toLowerCase().includes(lowerQuery) ||
collection.id.toString().includes(lowerQuery) ||
collection.desc?.toLowerCase().includes(lowerQuery)
)
setFilteredCollections(filtered)
}
/**
* Обрабатывает изменение поискового запроса
*/
const handleSearchChange = (value: string) => {
setSearchQuery(value)
filterCollections(collections(), value)
}
/**
* Обработчик поиска - применяет текущий поисковый запрос
*/
const handleSearch = () => {
filterCollections(collections(), searchQuery())
console.log('[CollectionsRoute] Search triggered with query:', searchQuery())
}
/**
* Форматирует дату
*/
@@ -179,20 +226,23 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
// Загружаем коллекции при монтировании компонента
onMount(() => {
void loadCollections()
setFilteredCollections(collections())
})
return (
<div class={styles.container}>
<div class={styles.header}>
<div style={{ display: 'flex', gap: '10px' }}>
<Button onClick={openCreateModal} variant="primary">
<TableControls
isLoading={loading()}
searchValue={searchQuery()}
onSearchChange={handleSearchChange}
onSearch={handleSearch}
searchPlaceholder="Поиск по названию, slug или ID..."
actions={
<button class={`${styles.button} ${styles.primary}`} onClick={openCreateModal}>
Создать коллекцию
</Button>
<Button onClick={loadCollections} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
</div>
</div>
</button>
}
/>
<Show
when={!loading()}
@@ -218,7 +268,7 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
</tr>
</thead>
<tbody>
<For each={collections()}>
<For each={filteredCollections()}>
{(collection) => (
<tr
onClick={() => openEditModal(collection)}

View File

@@ -1,4 +1,6 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { Component, createEffect, createSignal, For, on, onMount, Show, untrack } from 'solid-js'
import { useTableSort } from '../context/sort'
import { COMMUNITIES_SORT_CONFIG } from '../context/sortConfig'
import {
CREATE_COMMUNITY_MUTATION,
DELETE_COMMUNITY_MUTATION,
@@ -9,6 +11,8 @@ import CommunityEditModal from '../modals/CommunityEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
/**
* Интерфейс для сообщества (используем локальный интерфейс для совместимости)
@@ -43,11 +47,18 @@ interface CommunitiesRouteProps {
const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
const [communities, setCommunities] = createSignal<Community[]>([])
const [loading, setLoading] = createSignal(false)
const [editModal, setEditModal] = createSignal<{ show: boolean; community: Community | null }>({
const { sortState } = useTableSort()
const [editModal, setEditModal] = createSignal<{
show: boolean
community: Community | null
}>({
show: false,
community: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; community: Community | null }>({
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
community: Community | null
}>({
show: false,
community: null
})
@@ -61,6 +72,8 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
const loadCommunities = async () => {
setLoading(true)
try {
// Загружаем все сообщества без параметров сортировки
// Сортировка будет выполнена на клиенте
const response = await fetch('/graphql', {
method: 'POST',
headers: {
@@ -77,7 +90,10 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
throw new Error(result.errors[0].message)
}
setCommunities(result.data.get_communities_all || [])
// Получаем данные и сортируем их на клиенте
const communitiesData = result.data.get_communities_all || []
const sortedCommunities = sortCommunities(communitiesData)
setCommunities(sortedCommunities)
} catch (error) {
props.onError(`Ошибка загрузки сообществ: ${(error as Error).message}`)
} finally {
@@ -92,6 +108,51 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
}
/**
* Сортирует сообщества на клиенте в соответствии с текущим состоянием сортировки
*/
const sortCommunities = (communities: Community[]): Community[] => {
const { field, direction } = sortState()
return [...communities].sort((a, b) => {
let comparison = 0
switch (field) {
case 'id':
comparison = a.id - b.id
break
case 'name':
comparison = (a.name || '').localeCompare(b.name || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
case 'created_at':
comparison = a.created_at - b.created_at
break
case 'created_by': {
const aName = a.created_by?.name || a.created_by?.email || ''
const bName = b.created_by?.name || b.created_by?.email || ''
comparison = aName.localeCompare(bName, 'ru')
break
}
case 'shouts':
comparison = (a.stat?.shouts || 0) - (b.stat?.shouts || 0)
break
case 'followers':
comparison = (a.stat?.followers || 0) - (b.stat?.followers || 0)
break
case 'authors':
comparison = (a.stat?.authors || 0) - (b.stat?.authors || 0)
break
default:
comparison = a.id - b.id
}
return direction === 'desc' ? -comparison : comparison
})
}
/**
* Открывает модалку создания
*/
@@ -181,6 +242,26 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
}
}
// Пересортировка при изменении состояния сортировки
createEffect(
on([sortState], () => {
if (communities().length > 0) {
// Используем untrack для предотвращения бесконечной рекурсии
const currentCommunities = untrack(() => communities())
const sortedCommunities = sortCommunities(currentCommunities)
// Сравниваем текущий порядок с отсортированным, чтобы избежать лишних обновлений
const needsUpdate =
JSON.stringify(currentCommunities.map((c: Community) => c.id)) !==
JSON.stringify(sortedCommunities.map((c: Community) => c.id))
if (needsUpdate) {
setCommunities(sortedCommunities)
}
}
})
)
// Загружаем сообщества при монтировании компонента
onMount(() => {
void loadCommunities()
@@ -188,14 +269,15 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
return (
<div class={styles.container}>
<div class={styles.header}>
<Button onClick={loadCommunities} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
<Button variant="primary" onClick={openCreateModal}>
Создать сообщество
</Button>
</div>
<TableControls
onRefresh={loadCommunities}
isLoading={loading()}
actions={
<Button variant="primary" onClick={openCreateModal}>
Создать сообщество
</Button>
}
/>
<Show
when={!loading()}
@@ -209,15 +291,29 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<SortableHeader field="id" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="name" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Название
</SortableHeader>
<SortableHeader field="slug" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
<th>Описание</th>
<th>Создатель</th>
<th>Публикации</th>
<th>Подписчики</th>
<SortableHeader field="created_by" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Создатель
</SortableHeader>
<SortableHeader field="shouts" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Публикации
</SortableHeader>
<SortableHeader field="followers" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Подписчики
</SortableHeader>
<th>Авторы</th>
<th>Создано</th>
<SortableHeader field="created_at" allowedFields={COMMUNITIES_SORT_CONFIG.allowedFields}>
Создано
</SortableHeader>
<th>Действия</th>
</tr>
</thead>

View File

@@ -5,6 +5,7 @@ import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
import TableControls from '../ui/TableControls'
import { getAuthTokenFromCookie } from '../utils/auth'
/**
@@ -59,7 +60,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const [statusFilter, setStatusFilter] = createSignal('all')
const [pagination, setPagination] = createSignal({
page: 1,
perPage: 10,
perPage: 20,
total: 0,
totalPages: 1
})
@@ -69,18 +70,26 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const [selectAll, setSelectAll] = createSignal(false)
// Состояние для модального окна подтверждения удаления
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; invite: Invite | null }>({
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
invite: Invite | null
}>({
show: false,
invite: null
})
// Состояние для модального окна подтверждения пакетного удаления
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{ show: boolean }>({
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{
show: boolean
}>({
show: false
})
// Добавляю состояние сортировки
const [sortState, setSortState] = createSignal<SortState>({ field: null, direction: 'asc' })
const [sortState, setSortState] = createSignal<SortState>({
field: null,
direction: 'asc'
})
/**
* Загружает список приглашений с учетом фильтров и пагинации
@@ -122,7 +131,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
setInvites(data.invites || [])
setPagination({
page: data.page || 1,
perPage: data.perPage || 10,
perPage: data.perPage || 20,
total: data.total || 0,
totalPages: data.totalPages || 1
})
@@ -353,68 +362,49 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
return (
<div class={styles.container}>
{/* Новая компактная панель поиска и фильтров */}
<div class={styles.searchSection}>
<div class={styles.searchRow}>
<input
type="text"
placeholder="Поиск по приглашающему, приглашаемому, публикации..."
value={search()}
onInput={(e) => setSearch(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
class={styles.fullWidthSearch}
/>
</div>
<div class={styles.filtersRow}>
<select
value={statusFilter()}
onChange={(e) => handleStatusFilterChange(e.target.value)}
class={styles.statusFilter}
>
<option value="all">Все статусы</option>
<option value="pending">Ожидает ответа</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
<Button onClick={handleSearch} disabled={loading()}>
🔍 Поиск
</Button>
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
{loading() ? 'Загрузка...' : '🔄 Обновить'}
</Button>
</div>
</div>
{/* Панель пакетных действий */}
<Show when={!loading() && invites().length > 0}>
<div class={styles['batch-actions']}>
<div class={styles['select-all-container']}>
<input
type="checkbox"
id="select-all"
checked={selectAll()}
onChange={(e) => handleSelectAll(e.target.checked)}
class={styles.checkbox}
/>
<label for="select-all" class={styles['select-all-label']}>
Выбрать все
</label>
</div>
<TableControls
searchValue={search()}
onSearchChange={(value) => setSearch(value)}
onSearch={handleSearch}
searchPlaceholder="Поиск по приглашающему, приглашаемому, публикации..."
isLoading={loading()}
actions={
<Show when={getSelectedCount() > 0}>
<div class={styles['selected-count']}>Выбрано: {getSelectedCount()}</div>
<button
class={styles['batch-delete-button']}
class={`${styles.button} ${styles.danger}`}
onClick={() => setBatchDeleteModal({ show: true })}
title="Удалить выбранные приглашения"
>
Удалить выбранные
Удалить выбранные ({getSelectedCount()})
</button>
</Show>
}
>
<select
value={statusFilter()}
onChange={(e) => handleStatusFilterChange(e.target.value)}
class={styles.statusFilter}
>
<option value="all">Все статусы</option>
<option value="pending">Ожидает ответа</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
</TableControls>
{/* Панель выбора всех */}
<Show when={!loading() && invites().length > 0}>
<div class={styles['select-all-container']} style={{ 'margin-bottom': '10px' }}>
<input
type="checkbox"
id="select-all"
checked={selectAll()}
onChange={(e) => handleSelectAll(e.target.checked)}
class={styles.checkbox}
/>
<label for="select-all" class={styles['select-all-label']}>
Выбрать все
</label>
</div>
</Show>

View File

@@ -7,8 +7,10 @@ import { useNavigate } from '@solidjs/router'
import { createSignal, onMount } from 'solid-js'
import publyLogo from '../assets/publy.svg?url'
import { useAuth } from '../context/auth'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Login.module.css'
import Button from '../ui/Button'
import LanguageSwitcher from '../ui/LanguageSwitcher'
/**
* Компонент страницы входа
@@ -48,40 +50,72 @@ const LoginPage = () => {
return (
<div class={styles['login-container']}>
<form class={styles['login-form']} onSubmit={handleSubmit}>
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
<h1>Вход в панель администратора</h1>
<div class={styles['login-header']}>
<LanguageSwitcher />
</div>
<div class={styles['login-form-container']}>
<form class={formStyles.form} onSubmit={handleSubmit}>
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
<h1 class={formStyles.title}>Вход в админ панель</h1>
{error() && <div class={styles['error-message']}>{error()}</div>}
<div class={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>📧</span>
Email
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="email"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
placeholder="admin@discours.io"
required
class={`${formStyles.input} ${error() ? formStyles.error : ''}`}
disabled={loading()}
/>
</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={formStyles.fieldGroup}>
<label class={formStyles.label}>
<span class={formStyles.labelText}>
<span class={formStyles.labelIcon}>🔒</span>
Пароль
<span class={formStyles.required}>*</span>
</span>
</label>
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="••••••••"
required
class={`${formStyles.input} ${error() ? formStyles.error : ''}`}
disabled={loading()}
/>
</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>
{error() && (
<div class={formStyles.fieldError}>
<span class={formStyles.errorIcon}></span>
{error()}
</div>
)}
<Button type="submit" variant="primary" disabled={loading()} loading={loading()}>
{loading() ? 'Вход...' : 'Войти'}
</Button>
</form>
<div class={formStyles.actions}>
<Button
variant="primary"
type="submit"
loading={loading()}
disabled={loading() || !username() || !password()}
onClick={handleSubmit}
>
Войти
</Button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,4 +1,7 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { createEffect, createSignal, For, on, onMount, Show, untrack } from 'solid-js'
import { useData } from '../context/data'
import { useTableSort } from '../context/sort'
import { SHOUTS_SORT_CONFIG } from '../context/sortConfig'
import { query } from '../graphql'
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
@@ -6,6 +9,8 @@ import styles from '../styles/Admin.module.css'
import EditableCodePreview from '../ui/EditableCodePreview'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
import { formatDateRelative } from '../utils/date'
export interface ShoutsRouteProps {
@@ -13,13 +18,15 @@ export interface ShoutsRouteProps {
onSuccess?: (message: string) => void
}
const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
const ShoutsRoute = (props: ShoutsRouteProps) => {
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>('')
const { sortState } = useTableSort()
const { selectedCommunity } = useData()
// Pagination state
const [pagination, setPagination] = createSignal<{
@@ -43,16 +50,38 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
async function loadShouts() {
try {
setLoading(true)
// Подготавливаем параметры запроса
const variables: {
limit: number
offset: number
search?: string
community?: number
} = {
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
// Добавляем поиск если есть
if (searchQuery().trim()) {
variables.search = searchQuery().trim()
}
// Добавляем фильтр по сообществу если выбрано
const communityFilter = selectedCommunity()
if (communityFilter !== null) {
variables.community = communityFilter
}
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
`${location.origin}/graphql`,
ADMIN_GET_SHOUTS_QUERY,
{
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
variables
)
if (result?.adminGetShouts?.shouts) {
setShouts(result.adminGetShouts.shouts)
// Применяем сортировку на клиенте
const sortedShouts = sortShouts(result.adminGetShouts.shouts)
setShouts(sortedShouts)
setPagination((prev) => ({
...prev,
total: result.adminGetShouts.total || 0,
@@ -83,23 +112,80 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
void loadShouts()
}
// Helper functions
function getShoutStatus(shout: Shout): string {
if (shout.deleted_at) return '🗑️'
if (shout.published_at) return '✅'
return '📝'
/**
* Сортирует публикации на клиенте
*/
function sortShouts(shoutsData: Shout[]): Shout[] {
const { field, direction } = sortState()
return [...shoutsData].sort((a, b) => {
let comparison = 0
switch (field) {
case 'id':
comparison = Number(a.id) - Number(b.id)
break
case 'title':
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
case 'created_at':
comparison = (a.created_at || 0) - (b.created_at || 0)
break
case 'published_at':
comparison = (a.published_at || 0) - (b.published_at || 0)
break
case 'updated_at':
comparison = (a.updated_at || 0) - (b.updated_at || 0)
break
default:
comparison = Number(a.id) - Number(b.id)
}
return direction === 'desc' ? -comparison : comparison
})
}
// Пересортировка при изменении состояния сортировки
createEffect(
on([sortState], () => {
if (shouts().length > 0) {
// Используем untrack для предотвращения бесконечной рекурсии
const currentShouts = untrack(() => shouts())
const sortedShouts = sortShouts(currentShouts)
// Сравниваем текущий порядок с отсортированным, чтобы избежать лишних обновлений
const needsUpdate =
JSON.stringify(currentShouts.map((s: Shout) => s.id)) !==
JSON.stringify(sortedShouts.map((s: Shout) => s.id))
if (needsUpdate) {
setShouts(sortedShouts)
}
}
})
)
// Перезагрузка при изменении выбранного сообщества
createEffect(
on([selectedCommunity], () => {
void loadShouts()
})
)
// Helper functions
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 getShoutStatusBackgroundColor(shout: Shout): string {
if (shout.deleted_at) return '#fee2e2' // Пастельный красный
if (shout.published_at) return '#d1fae5' // Пастельный зеленый
return '#fef3c7' // Пастельный желтый для черновиков
}
function truncateText(text: string, maxLength = 100): string {
@@ -118,39 +204,33 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
</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>
<TableControls
onRefresh={loadShouts}
isLoading={loading()}
searchValue={searchQuery()}
onSearchChange={(value) => setSearchQuery(value)}
onSearch={() => void loadShouts()}
/>
<div class={styles['shouts-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Slug</th>
<th>Статус</th>
<SortableHeader field="id" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="title" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Заголовок
</SortableHeader>
<SortableHeader field="slug" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
<th>Авторы</th>
<th>Темы</th>
<th>Создан</th>
<SortableHeader field="created_at" allowedFields={SHOUTS_SORT_CONFIG.allowedFields}>
Создан
</SortableHeader>
<th>Содержимое</th>
<th>Media</th>
</tr>
@@ -159,17 +239,18 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<For each={shouts()}>
{(shout) => (
<tr>
<td>{shout.id}</td>
<td
style={{
'background-color': getShoutStatusBackgroundColor(shout),
padding: '8px 12px',
'border-radius': '4px'
}}
title={getShoutStatusTitle(shout)}
>
{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']}>
@@ -210,7 +291,8 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<span class={styles['no-data']}>-</span>
</Show>
</td>
<td>{formatDateRelative(shout.created_at)}</td>
<td>{formatDateRelative(shout.created_at)()}</td>
<td
class={styles['body-cell']}
onClick={() => {
@@ -227,20 +309,17 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<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"
style="padding: 4px; font-size: 14px; min-width: 24px; border-radius: 4px;"
onClick={() => {
setSelectedMediaBody(mediaItem?.body || '')
setShowMediaBodyModal(true)
}}
title={mediaItem?.title || idx().toString()}
>
👁 body
👁
</button>
</Show>
</div>
@@ -278,6 +357,8 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<EditableCodePreview
content={selectedShoutBody()}
maxHeight="85vh"
language="html"
autoFormat={true}
onContentChange={(newContent) => {
setSelectedShoutBody(newContent)
}}
@@ -302,6 +383,8 @@ const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
<EditableCodePreview
content={selectedMediaBody()}
maxHeight="85vh"
language="html"
autoFormat={true}
onContentChange={(newContent) => {
setSelectedMediaBody(newContent)
}}

View File

@@ -1,679 +1,250 @@
/**
* Компонент управления топиками
* @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 { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
import { GET_TOPICS_QUERY } from '../graphql/queries'
import { createEffect, createSignal, For, on, Show } from 'solid-js'
import { Topic, useData } from '../context/data'
import { useTableSort } from '../context/sort'
import { TOPICS_SORT_CONFIG } from '../context/sortConfig'
import TopicEditModal from '../modals/TopicEditModal'
import TopicMergeModal from '../modals/TopicMergeModal'
import TopicSimpleParentModal from '../modals/TopicSimpleParentModal'
import adminStyles from '../styles/Admin.module.css'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import SortableHeader from '../ui/SortableHeader'
import TableControls from '../ui/TableControls'
/**
* Интерфейс топика
*/
interface Topic {
id: number
slug: string
title: string
body?: string
pic?: string
community: number
parent_ids?: number[]
children?: Topic[]
level?: number
interface TopicsProps {
onError?: (message: string) => void
onSuccess?: (message: string) => void
}
/**
* Интерфейс свойств компонента
*/
interface TopicsRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
export const Topics = (props: TopicsProps) => {
const { selectedCommunity, loadTopicsByCommunity, topics: contextTopics } = useData()
/**
* Компонент управления топиками
*/
const TopicsRoute: Component<TopicsRouteProps> = (props) => {
const [rawTopics, setRawTopics] = createSignal<Topic[]>([])
const [topics, setTopics] = createSignal<Topic[]>([])
// Состояние поиска
const [searchQuery, setSearchQuery] = createSignal('')
// Состояние загрузки
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 [createModal, setCreateModal] = createSignal<{ show: boolean }>({
show: false
})
const [selectedTopics, setSelectedTopics] = createSignal<number[]>([])
const [groupAction, setGroupAction] = createSignal<'delete' | 'merge' | ''>('')
const [mergeModal, setMergeModal] = createSignal<{ show: boolean }>({
show: false
})
const [simpleParentModal, setSimpleParentModal] = createSignal<{ show: boolean; topic: Topic | null }>({
show: false,
topic: null
})
// Модальное окно для редактирования топика
const [showEditModal, setShowEditModal] = createSignal(false)
const [selectedTopic, setSelectedTopic] = createSignal<Topic | undefined>(undefined)
// Сортировка
const { sortState } = useTableSort()
/**
* Загружает список всех топиков
* Загрузка топиков для сообщества
*/
const loadTopics = async () => {
setLoading(true)
try {
const data = await query<{ get_topics_all: Query['get_topics_all'] }>(
`${location.origin}/graphql`,
GET_TOPICS_QUERY
)
async function loadTopicsForCommunity() {
const community = selectedCommunity()
// selectedCommunity теперь всегда число (по умолчанию 1)
if (data?.get_topics_all) {
// Строим иерархическую структуру
const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null)
setRawTopics(validTopics)
}
console.log('[TopicsRoute] Loading all topics for community...')
try {
setLoading(true)
// Загружаем все топики сообщества
await loadTopicsByCommunity(community!)
console.log('[TopicsRoute] All topics loaded')
} catch (error) {
props.onError(`Ошибка загрузки топиков: ${(error as Error).message}`)
console.error('[TopicsRoute] Failed to load topics:', error)
props.onError?.(error instanceof Error ? 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 handleSearch = () => {
// Поиск осуществляется через filteredTopics(), которая реагирует на searchQuery()
// Дополнительная логика поиска здесь не нужна, но можно добавить аналитику
console.log('[TopicsRoute] Search triggered with query:', searchQuery())
}
/**
* Сортирует топики рекурсивно
* Фильтрация топиков по поисковому запросу
*/
const sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => {
const field = sortField || sortBy()
const direction = sortDir || sortDirection()
const filteredTopics = () => {
const topics = contextTopics()
const query = searchQuery().toLowerCase()
const sortedTopics = topics.sort((a, b) => {
if (!query) return topics
return topics.filter(
(topic) =>
topic.title?.toLowerCase().includes(query) ||
topic.slug?.toLowerCase().includes(query) ||
topic.id.toString().includes(query)
)
}
/**
* Сортировка топиков на клиенте
*/
const sortedTopics = () => {
const topics = filteredTopics()
const { field, direction } = sortState()
return [...topics].sort((a, b) => {
let comparison = 0
if (field === 'title') {
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
} else {
comparison = a.id - b.id
switch (field) {
case 'id':
comparison = a.id - b.id
break
case 'title':
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
break
case 'slug':
comparison = (a.slug || '').localeCompare(b.slug || '', 'ru')
break
default:
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
}
/**
* Обрезает текст до указанной длины
*/
// Загрузка при смене сообщества
createEffect(
on(selectedCommunity, (updatedCommunity) => {
if (updatedCommunity) {
// selectedCommunity теперь всегда число, поэтому всегда загружаем
void loadTopicsForCommunity()
}
})
)
const truncateText = (text: string, maxLength = 100): string => {
if (!text) return '—'
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
if (!text || text.length <= maxLength) return text
return `${text.substring(0, maxLength)}...`
}
/**
* Рекурсивно отображает топики с отступами для иерархии
* Открытие модального окна редактирования топика
*/
const renderTopics = (topics: Topic[]): JSX.Element[] => {
const result: JSX.Element[] = []
topics.forEach((topic) => {
const isSelected = selectedTopics().includes(topic.id)
result.push(
<tr class={styles['clickable-row']}>
<td>{topic.id}</td>
<td
style={{ 'padding-left': `${(topic.level || 0) * 20}px`, cursor: 'pointer' }}
onClick={() => setEditModal({ show: true, topic })}
>
{topic.level! > 0 && '└─ '}
{topic.title}
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
{topic.slug}
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
<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 onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
{topic.community}
</td>
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
{topic.parent_ids?.join(', ') || '—'}
</td>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation()
handleTopicSelect(topic.id, e.target.checked)
}}
style={{ cursor: 'pointer' }}
/>
</td>
</tr>
)
if (topic.children && topic.children.length > 0) {
result.push(...renderTopics(topic.children))
}
})
return result
const handleTopicEdit = (topic: Topic) => {
console.log('[TopicsRoute] Opening edit modal for topic:', topic)
setSelectedTopic(topic)
setShowEditModal(true)
}
/**
* Обновляет топик
* Сохранение изменений топика
*/
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 handleTopicSave = (updatedTopic: Topic) => {
console.log('[TopicsRoute] Saving topic:', updatedTopic)
const result = await response.json()
// TODO: добавить логику сохранения изменений в базу данных
// await updateTopic(updatedTopic)
if (result.errors) {
throw new Error(result.errors[0].message)
}
props.onSuccess?.('Топик успешно обновлён')
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}`)
}
// Обновляем локальные данные (пока что просто перезагружаем)
void loadTopicsForCommunity()
}
/**
* Создает новый топик
* Обработка ошибок из модального окна
*/
const createTopic = async (newTopic: Topic) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: CREATE_TOPIC_MUTATION,
variables: { topic_input: newTopic }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.create_topic.error) {
throw new Error(result.data.create_topic.error)
}
props.onSuccess('Топик успешно создан')
setCreateModal({ show: false })
await loadTopics() // Перезагружаем список
} catch (error) {
props.onError(`Ошибка создания топика: ${(error as Error).message}`)
}
const handleTopicError = (message: string) => {
props.onError?.(message)
}
/**
* Обработчик выбора/снятия выбора топика
* Рендер строки топика
*/
const handleTopicSelect = (topicId: number, checked: boolean) => {
if (checked) {
setSelectedTopics((prev) => [...prev, topicId])
} else {
setSelectedTopics((prev) => prev.filter((id) => id !== topicId))
}
}
/**
* Обработчик выбора/снятия выбора всех топиков
*/
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allTopicIds = rawTopics().map((topic) => topic.id)
setSelectedTopics(allTopicIds)
} else {
setSelectedTopics([])
}
}
/**
* Проверяет выбраны ли все топики
*/
const isAllSelected = () => {
const allIds = rawTopics().map((topic) => topic.id)
const selected = selectedTopics()
return allIds.length > 0 && allIds.every((id) => selected.includes(id))
}
/**
* Проверяет выбран ли хотя бы один топик
*/
const hasSelectedTopics = () => selectedTopics().length > 0
/**
* Выполняет групповое действие
*/
const executeGroupAction = () => {
const action = groupAction()
const selected = selectedTopics()
if (!action || selected.length === 0) {
props.onError('Выберите действие и топики')
return
}
if (action === 'delete') {
// Групповое удаление
const selectedTopicsData = rawTopics().filter((t) => selected.includes(t.id))
setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения
} else if (action === 'merge') {
// Слияние топиков
if (selected.length < 2) {
props.onError('Для слияния нужно выбрать минимум 2 темы')
return
}
setMergeModal({ show: true })
}
}
/**
* Групповое удаление выбранных топиков
*/
const deleteSelectedTopics = async () => {
const selected = selectedTopics()
if (selected.length === 0) return
try {
// Удаляем по одному (можно оптимизировать пакетным удалением)
for (const topicId of selected) {
await deleteTopic(topicId)
}
setSelectedTopics([])
setGroupAction('')
props.onSuccess(`Успешно удалено ${selected.length} тем`)
} 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}`)
}
}
const renderTopicRow = (topic: Topic) => (
<tr
class={styles.tableRow}
onClick={() => handleTopicEdit(topic)}
style="cursor: pointer;"
title="Нажмите для редактирования топика"
>
<td class={styles.tableCell}>{topic.id}</td>
<td class={styles.tableCell}>
<strong title={topic.title}>{truncateText(topic.title, 50)}</strong>
</td>
<td class={styles.tableCell} title={topic.slug}>
{truncateText(topic.slug, 30)}
</td>
<td class={styles.tableCell}>
{topic.body ? (
<span style="color: #666;">{truncateText(topic.body.replace(/<[^>]*>/g, ''), 60)}</span>
) : (
<span style="color: #999; font-style: italic;">Нет содержимого</span>
)}
</td>
</tr>
)
return (
<div class={styles.container}>
<div class={styles.header}>
<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>
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
Создать тему
</Button>
<Button
variant="secondary"
onClick={() => {
if (selectedTopics().length === 1) {
const selectedTopic = rawTopics().find((t) => t.id === selectedTopics()[0])
if (selectedTopic) {
setSimpleParentModal({ show: true, topic: selectedTopic })
}
} else {
props.onError('Выберите одну тему для назначения родителя')
}
}}
>
🏠 Назначить родителя
</Button>
</div>
</div>
<div class={adminStyles.pageContainer}>
<TableControls
searchValue={searchQuery()}
onSearchChange={setSearchQuery}
onSearch={handleSearch}
searchPlaceholder="Поиск по названию, slug или ID..."
isLoading={loading()}
onRefresh={loadTopicsForCommunity}
/>
<Show
when={!loading()}
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка топиков...</div>
</div>
}
>
<div class={styles.tableContainer}>
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<th>Описание</th>
<th>Сообщество</th>
<th>Родители</th>
<th>
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px',
'flex-direction': 'column'
}}
>
<div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
<input
type="checkbox"
checked={isAllSelected()}
onChange={(e) => handleSelectAll(e.target.checked)}
style={{ cursor: 'pointer' }}
title="Выбрать все"
/>
<span style={{ 'font-size': '12px' }}>Все</span>
</div>
<Show when={hasSelectedTopics()}>
<div style={{ display: 'flex', gap: '4px', 'align-items': 'center' }}>
<select
value={groupAction()}
onChange={(e) => setGroupAction(e.target.value as 'delete' | 'merge' | '')}
style={{
padding: '2px 4px',
'font-size': '11px',
border: '1px solid #ddd',
'border-radius': '3px'
}}
>
<option value="">Действие</option>
<option value="delete">Удалить</option>
<option value="merge">Слить</option>
</select>
<button
onClick={executeGroupAction}
disabled={!groupAction()}
style={{
padding: '2px 6px',
'font-size': '11px',
background: groupAction() ? '#007bff' : '#ccc',
color: 'white',
border: 'none',
'border-radius': '3px',
cursor: groupAction() ? 'pointer' : 'not-allowed'
}}
>
</button>
</div>
</Show>
</div>
</th>
<tr class={styles.tableHeader}>
<SortableHeader field="id" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
ID
</SortableHeader>
<SortableHeader field="title" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
Название
</SortableHeader>
<SortableHeader field="slug" allowedFields={TOPICS_SORT_CONFIG.allowedFields}>
Slug
</SortableHeader>
<th class={styles.tableHeaderCell}>Body</th>
</tr>
</thead>
<tbody>
<For each={renderTopics(topics())}>{(row) => row}</For>
<Show when={loading()}>
<tr>
<td colspan="4" class={styles.loadingCell}>
Загрузка...
</td>
</tr>
</Show>
<Show when={!loading() && sortedTopics().length === 0}>
<tr>
<td colspan="4" class={styles.emptyCell}>
Нет топиков
</td>
</tr>
</Show>
<Show when={!loading()}>
<For each={sortedTopics()}>{renderTopicRow}</For>
</Show>
</tbody>
</table>
</Show>
</div>
{/* Модальное окно создания */}
<div class={styles.tableFooter}>
<span class={styles.resultsInfo}>
<span>Всего</span>: {sortedTopics().length}
</span>
</div>
{/* Модальное окно для редактирования топика */}
<TopicEditModal
isOpen={createModal().show}
topic={null}
onClose={() => setCreateModal({ show: false })}
onSave={createTopic}
/>
{/* Модальное окно редактирования */}
<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>
<Show when={selectedTopics().length > 1}>
<p>
Вы уверены, что хотите удалить <strong>{selectedTopics().length}</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={deleteSelectedTopics}>
Удалить {selectedTopics().length} тем
</Button>
</div>
</Show>
<Show when={selectedTopics().length <= 1}>
<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={() => {
if (deleteModal().topic) {
void deleteTopic(deleteModal().topic!.id)
}
}}
>
Удалить
</Button>
</div>
</Show>
</div>
</Modal>
{/* Модальное окно слияния тем */}
<TopicMergeModal
isOpen={mergeModal().show}
isOpen={showEditModal()}
topic={selectedTopic()!}
onClose={() => {
setMergeModal({ show: false })
setSelectedTopics([])
setGroupAction('')
setShowEditModal(false)
setSelectedTopic(undefined)
}}
topics={rawTopics().filter((topic) => selectedTopics().includes(topic.id))}
onSuccess={(message) => {
props.onSuccess(message)
setSelectedTopics([])
setGroupAction('')
void loadTopics()
}}
onError={props.onError}
/>
{/* Модальное окно назначения родителя */}
<TopicSimpleParentModal
isOpen={simpleParentModal().show}
onClose={() => setSimpleParentModal({ show: false, topic: null })}
topic={simpleParentModal().topic}
allTopics={rawTopics()}
onSuccess={(message) => {
props.onSuccess(message)
setSimpleParentModal({ show: false, topic: null })
void loadTopics() // Перезагружаем данные
}}
onError={props.onError}
onSave={handleTopicSave}
onError={handleTopicError}
/>
</div>
)
}
export default TopicsRoute