This commit is contained in:
203
panel/routes/admin.tsx
Normal file
203
panel/routes/admin.tsx
Normal 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
|
@@ -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>
|
||||
|
@@ -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)}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
||||
|
@@ -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)
|
||||
}}
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user