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

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

283
panel/routes/authors.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { query } from '../graphql'
import type { Query, AdminUserInfo as User } from '../graphql/generated/schema'
import { ADMIN_UPDATE_USER_MUTATION } from '../graphql/mutations'
import { ADMIN_GET_USERS_QUERY } from '../graphql/queries'
import UserEditModal from '../modals/RolesModal'
import styles from '../styles/Admin.module.css'
import Pagination from '../ui/Pagination'
import { formatDateRelative } from '../utils/date'
export interface AuthorsRouteProps {
onError?: (error: string) => void
onSuccess?: (message: string) => void
}
const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
console.log('[AuthorsRoute] Initializing...')
const [authors, setUsers] = createSignal<User[]>([])
const [loading, setLoading] = createSignal(true)
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
const [showEditModal, setShowEditModal] = createSignal(false)
// Pagination state
const [pagination, setPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 10,
total: 0,
totalPages: 1
})
// Search state
const [searchQuery, setSearchQuery] = createSignal('')
/**
* Загрузка списка пользователей с учетом пагинации и поиска
*/
async function loadUsers() {
console.log('[AuthorsRoute] Loading authors...')
try {
setLoading(true)
const data = await query<{ adminGetUsers: Query['adminGetUsers'] }>(
`${location.origin}/graphql`,
ADMIN_GET_USERS_QUERY,
{
search: searchQuery(),
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
)
if (data?.adminGetUsers?.authors) {
console.log('[AuthorsRoute] Users loaded:', data.adminGetUsers.authors.length)
setUsers(data.adminGetUsers.authors)
setPagination((prev) => ({
...prev,
total: data.adminGetUsers.total || 0,
totalPages: data.adminGetUsers.totalPages || 1
}))
}
} catch (error) {
console.error('[AuthorsRoute] Failed to load authors:', error)
props.onError?.(error instanceof Error ? error.message : 'Failed to load authors')
} finally {
setLoading(false)
}
}
/**
* Обновляет данные пользователя (профиль и роли)
*/
async function updateUser(userData: {
id: number
email?: string
name?: string
slug?: string
roles: string[]
}) {
try {
await query(`${location.origin}/graphql`, ADMIN_UPDATE_USER_MUTATION, {
user: userData
})
setUsers((prev) =>
prev.map((user) => {
if (user.id === userData.id) {
return {
...user,
email: userData.email || user.email,
name: userData.name || user.name,
slug: userData.slug || user.slug,
roles: userData.roles
}
}
return user
})
)
closeEditModal()
props.onSuccess?.('Данные пользователя успешно обновлены')
void loadUsers()
} catch (err) {
console.error('Ошибка обновления пользователя:', err)
let errorMessage = err instanceof Error ? err.message : 'Ошибка обновления данных пользователя'
if (errorMessage.includes('author_role.community')) {
errorMessage = 'Ошибка: для роли author требуется указать community. Обратитесь к администратору.'
}
props.onError?.(errorMessage)
}
}
function closeEditModal() {
setShowEditModal(false)
setSelectedUser(null)
}
// Pagination handlers
function handlePageChange(page: number) {
setPagination((prev) => ({ ...prev, page }))
void loadUsers()
}
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
void loadUsers()
}
// Search handlers
function handleSearchChange(e: Event) {
const input = e.target as HTMLInputElement
setSearchQuery(input.value)
}
function handleSearch() {
setPagination((prev) => ({ ...prev, page: 1 }))
void loadUsers()
}
function handleSearchKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}
// Load authors on mount
onMount(() => {
console.log('[AuthorsRoute] Component mounted, loading authors...')
void loadUsers()
})
/**
* Компонент для отображения роли с иконкой
*/
const RoleBadge: Component<{ role: string }> = (props) => {
const getRoleIcon = (role: string): string => {
switch (role.toLowerCase()) {
case 'admin':
return '👑'
case 'editor':
return '✏️'
case 'expert':
return '🎓'
case 'author':
return '📝'
case 'reader':
return '👤'
case 'banned':
return '🚫'
case 'verified':
return '✓'
default:
return '👤'
}
}
return (
<span class="role-badge" title={props.role}>
<span class="role-icon">{getRoleIcon(props.role)}</span>
<span class="role-name">{props.role}</span>
</span>
)
}
return (
<div class={styles['authors-container']}>
<Show when={loading()}>
<div class={styles['loading']}>Загрузка данных...</div>
</Show>
<Show when={!loading() && authors().length === 0}>
<div class={styles['empty-state']}>Нет данных для отображения</div>
</Show>
<Show when={!loading() && authors().length > 0}>
<div class={styles['authors-controls']}>
<div class={styles['search-container']}>
<div class={styles['search-input-group']}>
<input
type="text"
placeholder="Поиск по email, имени или ID..."
value={searchQuery()}
onInput={handleSearchChange}
onKeyDown={handleSearchKeyDown}
class={styles['search-input']}
/>
<button class={styles['search-button']} onClick={handleSearch}>
Поиск
</button>
</div>
</div>
</div>
<div class={styles['authors-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Имя</th>
<th>Создан</th>
<th>Роли</th>
</tr>
</thead>
<tbody>
<For each={authors()}>
{(user) => (
<tr>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{formatDateRelative(user.created_at || Date.now())}</td>
<td class={styles['roles-cell']}>
<div class={styles['roles-container']}>
<For each={Array.from(user.roles || []).filter(Boolean)}>
{(role) => <RoleBadge role={role} />}
</For>
<div
class={styles['role-badge edit-role-badge']}
onClick={() => {
setSelectedUser(user)
setShowEditModal(true)
}}
>
<span class={styles['role-icon']}>🎭</span>
</div>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Pagination
currentPage={pagination().page}
totalPages={pagination().totalPages}
total={pagination().total}
limit={pagination().limit}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
/>
</Show>
<Show when={showEditModal() && selectedUser()}>
<UserEditModal
user={selectedUser()!}
isOpen={showEditModal()}
onClose={closeEditModal}
onSave={updateUser}
/>
</Show>
</div>
)
}
export default AuthorsRoute

View File

@@ -0,0 +1,381 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { DELETE_COMMUNITY_MUTATION, UPDATE_COMMUNITY_MUTATION } from '../graphql/mutations'
import { GET_COMMUNITIES_QUERY } from '../graphql/queries'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
/**
* Интерфейс для сообщества (используем локальный интерфейс для совместимости)
*/
interface Community {
id: number
slug: string
name: string
desc?: string
pic: string
created_at: number
created_by: {
id: number
name: string
email: string
}
stat: {
shouts: number
followers: number
authors: number
}
}
interface CommunitiesRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
/**
* Компонент для управления сообществами
*/
const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
const [communities, setCommunities] = createSignal<Community[]>([])
const [loading, setLoading] = createSignal(false)
const [editModal, setEditModal] = createSignal<{ show: boolean; community: Community | null }>({
show: false,
community: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; community: Community | null }>({
show: false,
community: null
})
// Форма для редактирования
const [formData, setFormData] = createSignal({
slug: '',
name: '',
desc: '',
pic: ''
})
/**
* Загружает список всех сообществ
*/
const loadCommunities = async () => {
setLoading(true)
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: GET_COMMUNITIES_QUERY
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
setCommunities(result.data.get_communities_all || [])
} catch (error) {
props.onError(`Ошибка загрузки сообществ: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
/**
* Форматирует дату
*/
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
}
/**
* Открывает модалку редактирования
*/
const openEditModal = (community: Community) => {
setFormData({
slug: community.slug,
name: community.name,
desc: community.desc || '',
pic: community.pic
})
setEditModal({ show: true, community })
}
/**
* Обновляет сообщество
*/
const updateCommunity = async () => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: UPDATE_COMMUNITY_MUTATION,
variables: { community_input: formData() }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.update_community.error) {
throw new Error(result.data.update_community.error)
}
props.onSuccess('Сообщество успешно обновлено')
setEditModal({ show: false, community: null })
await loadCommunities()
} catch (error) {
props.onError(`Ошибка обновления сообщества: ${(error as Error).message}`)
}
}
/**
* Удаляет сообщество
*/
const deleteCommunity = async (slug: string) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: DELETE_COMMUNITY_MUTATION,
variables: { slug }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.delete_community.error) {
throw new Error(result.data.delete_community.error)
}
props.onSuccess('Сообщество успешно удалено')
setDeleteModal({ show: false, community: null })
await loadCommunities()
} catch (error) {
props.onError(`Ошибка удаления сообщества: ${(error as Error).message}`)
}
}
// Загружаем сообщества при монтировании компонента
onMount(() => {
void loadCommunities()
})
return (
<div class={styles.container}>
<div class={styles.header}>
<h2>Управление сообществами</h2>
<Button onClick={loadCommunities} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
</div>
<Show
when={!loading()}
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка сообществ...</div>
</div>
}
>
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<th>Описание</th>
<th>Создатель</th>
<th>Публикации</th>
<th>Подписчики</th>
<th>Авторы</th>
<th>Создано</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={communities()}>
{(community) => (
<tr
onClick={() => openEditModal(community)}
style={{ cursor: 'pointer' }}
class={styles['clickable-row']}
>
<td>{community.id}</td>
<td>{community.name}</td>
<td>{community.slug}</td>
<td>
<div
style={{
'max-width': '200px',
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap'
}}
title={community.desc}
>
{community.desc || '—'}
</div>
</td>
<td>{community.created_by.name || community.created_by.email}</td>
<td>{community.stat.shouts}</td>
<td>{community.stat.followers}</td>
<td>{community.stat.authors}</td>
<td>{formatDate(community.created_at)}</td>
<td onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation()
setDeleteModal({ show: true, community })
}}
class={styles['delete-button']}
title="Удалить сообщество"
aria-label="Удалить сообщество"
>
×
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
{/* Модальное окно редактирования */}
<Modal
isOpen={editModal().show}
onClose={() => setEditModal({ show: false, community: null })}
title={`Редактирование сообщества: ${editModal().community?.name || ''}`}
>
<div style={{ padding: '20px' }}>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
<input
type="text"
value={formData().slug}
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
required
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Название
</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Описание
</label>
<textarea
value={formData().desc}
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px',
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание сообщества..."
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Картинка (URL)
</label>
<input
type="text"
value={formData().pic}
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
placeholder="https://example.com/image.jpg"
/>
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setEditModal({ show: false, community: null })}>
Отмена
</Button>
<Button variant="primary" onClick={updateCommunity}>
Сохранить
</Button>
</div>
</div>
</Modal>
{/* Модальное окно подтверждения удаления */}
<Modal
isOpen={deleteModal().show}
onClose={() => setDeleteModal({ show: false, community: null })}
title="Подтверждение удаления"
>
<div>
<p>
Вы уверены, что хотите удалить сообщество "<strong>{deleteModal().community?.name}</strong>"?
</p>
<p class={styles['warning-text']}>
Это действие нельзя отменить. Все публикации и темы сообщества могут быть затронуты.
</p>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, community: null })}>
Отмена
</Button>
<Button
variant="danger"
onClick={() => deleteModal().community && deleteCommunity(deleteModal().community!.slug)}
>
Удалить
</Button>
</div>
</div>
</Modal>
</div>
)
}
export default CommunitiesRoute

275
panel/routes/env.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { Component, createSignal, For, Show } from 'solid-js'
import { query } from '../graphql'
import type { EnvSection, EnvVariable, Query } from '../graphql/generated/schema'
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
import { ADMIN_GET_ENV_VARIABLES_QUERY } from '../graphql/queries'
import EnvVariableModal from '../modals/EnvVariableModal'
import styles from '../styles/Admin.module.css'
import Button from '../ui/Button'
export interface EnvRouteProps {
onError?: (error: string) => void
onSuccess?: (message: string) => void
}
const EnvRoute: Component<EnvRouteProps> = (props) => {
const [envSections, setEnvSections] = createSignal<EnvSection[]>([])
const [loading, setLoading] = createSignal(true)
const [editingVariable, setEditingVariable] = createSignal<EnvVariable | null>(null)
const [showVariableModal, setShowVariableModal] = createSignal(false)
// Состояние для показа/скрытия значений
const [shownVars, setShownVars] = createSignal<{ [key: string]: boolean }>({})
/**
* Загружает переменные окружения
*/
const loadEnvVariables = async () => {
try {
setLoading(true)
const result = await query<{ getEnvVariables: Query['getEnvVariables'] }>(
`${location.origin}/graphql`,
ADMIN_GET_ENV_VARIABLES_QUERY
)
// Важно: пустой массив [] тоже валидный результат!
if (result && Array.isArray(result.getEnvVariables)) {
setEnvSections(result.getEnvVariables)
console.log('Загружено секций переменных:', result.getEnvVariables.length)
} else {
console.warn('Неожиданный результат от getEnvVariables:', result)
setEnvSections([]) // Устанавливаем пустой массив если что-то пошло не так
}
} catch (error) {
console.error('Failed to load env variables:', error)
props.onError?.(error instanceof Error ? error.message : 'Failed to load environment variables')
setEnvSections([]) // Устанавливаем пустой массив при ошибке
} finally {
setLoading(false)
}
}
/**
* Обновляет значение переменной окружения
*/
const updateEnvVariable = async (key: string, value: string) => {
try {
const result = await query(`${location.origin}/graphql`, ADMIN_UPDATE_ENV_VARIABLE_MUTATION, {
key,
value
})
if (result && typeof result === 'object' && 'updateEnvVariable' in result) {
props.onSuccess?.(`Переменная ${key} успешно обновлена`)
await loadEnvVariables()
} else {
props.onError?.('Не удалось обновить переменную')
}
} catch (err) {
console.error('Ошибка обновления переменной:', err)
props.onError?.(err instanceof Error ? err.message : 'Ошибка при обновлении переменной')
}
}
/**
* Обработчик открытия модального окна редактирования переменной
*/
const openVariableModal = (variable: EnvVariable) => {
setEditingVariable({ ...variable })
setShowVariableModal(true)
}
/**
* Обработчик закрытия модального окна редактирования переменной
*/
const closeVariableModal = () => {
setEditingVariable(null)
setShowVariableModal(false)
}
/**
* Обработчик сохранения переменной
*/
const saveVariable = async () => {
const variable = editingVariable()
if (!variable) return
await updateEnvVariable(variable.key, variable.value)
closeVariableModal()
}
/**
* Обработчик изменения значения в модальном окне
*/
const handleVariableValueChange = (value: string) => {
const variable = editingVariable()
if (variable) {
setEditingVariable({ ...variable, value })
}
}
/**
* Переключает показ значения переменной
*/
const toggleShow = (key: string) => {
setShownVars((prev) => ({ ...prev, [key]: !prev[key] }))
}
/**
* Копирует значение в буфер обмена
*/
const CopyButton: Component<{ value: string }> = (props) => {
const handleCopy = async (e: MouseEvent) => {
e.preventDefault()
try {
await navigator.clipboard.writeText(props.value)
// Можно добавить всплывающее уведомление
} catch (err) {
alert(`Ошибка копирования: ${(err as Error).message}`)
}
}
return (
<a class="btn" title="Скопировать" type="button" style="margin-left: 6px" onClick={handleCopy}>
📋
</a>
)
}
/**
* Кнопка показать/скрыть значение переменной
*/
const ShowHideButton: Component<{ shown: boolean; onToggle: () => void }> = (props) => {
return (
<a
class="btn"
title={props.shown ? 'Скрыть' : 'Показать'}
type="button"
style="margin-left: 6px"
onClick={props.onToggle}
>
{props.shown ? '🙈' : '👁️'}
</a>
)
}
// Load env variables on mount
void loadEnvVariables()
// ВРЕМЕННО: для тестирования пустого состояния
// setTimeout(() => {
// setLoading(false)
// setEnvSections([])
// console.log('Тест: установлено пустое состояние')
// }, 1000)
return (
<div class={styles['env-variables-container']}>
<Show when={loading()}>
<div class={styles['loading']}>Загрузка переменных окружения...</div>
</Show>
<Show when={!loading() && envSections().length === 0}>
<div class={styles['empty-state']}>
<h3>Переменные окружения не найдены</h3>
<p>
Переменные окружения не настроены или не обнаружены в системе.
<br />
Вы можете добавить переменные через файл <code>.env</code> или системные переменные.
</p>
<details style="margin-top: 16px;">
<summary style="cursor: pointer; font-weight: 600;">Как добавить переменные?</summary>
<div style="margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
<p>
<strong>Способ 1:</strong> Через командную строку
</p>
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
export DEBUG=true export DB_URL="postgresql://localhost:5432/db" export
REDIS_URL="redis://localhost:6379"
</pre>
<p style="margin-top: 12px;">
<strong>Способ 2:</strong> Через файл .env
</p>
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
DEBUG=true DB_URL=postgresql://localhost:5432/db REDIS_URL=redis://localhost:6379
</pre>
</div>
</details>
</div>
</Show>
<Show when={!loading() && envSections().length > 0}>
<div class={styles['env-sections']}>
<For each={envSections()}>
{(section) => (
<div class={styles['env-section']}>
<h3 class={styles['section-name']}>{section.name}</h3>
<Show when={section.description}>
<p class={styles['section-description']}>{section.description}</p>
</Show>
<div class={styles['variables-list']}>
<table>
<thead>
<tr>
<th>Ключ</th>
<th>Значение</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={section.variables}>
{(variable) => {
const shown = () => shownVars()[variable.key] || false
return (
<tr>
<td>{variable.key}</td>
<td>
{variable.isSecret && !shown()
? '••••••••'
: variable.value || <span class={styles['empty-value']}>не задано</span>}
<CopyButton value={variable.value || ''} />
{variable.isSecret && (
<ShowHideButton
shown={shown()}
onToggle={() => toggleShow(variable.key)}
/>
)}
</td>
<td>{variable.description || '-'}</td>
<td class={styles['actions']}>
<Button
variant="secondary"
size="small"
onClick={() => openVariableModal(variable)}
>
Изменить
</Button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={editingVariable()}>
<EnvVariableModal
isOpen={showVariableModal()}
variable={editingVariable()!}
onClose={closeVariableModal}
onSave={saveVariable}
onValueChange={handleVariableValueChange}
/>
</Show>
</div>
)
}
export default EnvRoute

89
panel/routes/login.tsx Normal file
View File

@@ -0,0 +1,89 @@
/**
* Компонент страницы входа
* @module LoginPage
*/
import { useNavigate } from '@solidjs/router'
import { createSignal, onMount } from 'solid-js'
import publyLogo from '../assets/publy.svg?url'
import { useAuth } from '../context/auth'
import styles from '../styles/Login.module.css'
import Button from '../ui/Button'
/**
* Компонент страницы входа
*/
const LoginPage = () => {
console.log('[LoginPage] Initializing...')
const [username, setUsername] = createSignal('')
const [password, setPassword] = createSignal('')
const [error, setError] = createSignal<string | null>(null)
const [loading, setLoading] = createSignal(false)
const auth = useAuth()
const navigate = useNavigate()
onMount(() => {
console.log('[LoginPage] Component mounted')
// Если пользователь уже авторизован, редиректим на админ-панель
if (auth.isAuthenticated()) {
console.log('[LoginPage] User already authenticated, redirecting to admin...')
navigate('/admin')
}
})
const handleSubmit = async (e: Event) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await auth.login(username(), password())
navigate('/admin')
} catch (error) {
setError(error instanceof Error ? error.message : 'Ошибка при входе')
} finally {
setLoading(false)
}
}
return (
<div class={styles['login-container']}>
<form class={styles['login-form']} onSubmit={handleSubmit}>
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
<h1>Вход в панель администратора</h1>
{error() && <div class={styles['error-message']}>{error()}</div>}
<div class={styles['form-group']}>
<label for="username">Имя пользователя</label>
<input
id="username"
type="text"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
disabled={loading()}
required
/>
</div>
<div class={styles['form-group']}>
<label for="password">Пароль</label>
<input
id="password"
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
disabled={loading()}
required
/>
</div>
<Button type="submit" variant="primary" disabled={loading()} loading={loading()}>
{loading() ? 'Вход...' : 'Войти'}
</Button>
</form>
</div>
)
}
export default LoginPage

317
panel/routes/shouts.tsx Normal file
View File

@@ -0,0 +1,317 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { query } from '../graphql'
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
import styles from '../styles/Admin.module.css'
import EditableCodePreview from '../ui/EditableCodePreview'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
import { formatDateRelative } from '../utils/date'
export interface ShoutsRouteProps {
onError?: (error: string) => void
onSuccess?: (message: string) => void
}
const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
const [shouts, setShouts] = createSignal<Shout[]>([])
const [loading, setLoading] = createSignal(true)
const [showBodyModal, setShowBodyModal] = createSignal(false)
const [selectedShoutBody, setSelectedShoutBody] = createSignal<string>('')
const [showMediaBodyModal, setShowMediaBodyModal] = createSignal(false)
const [selectedMediaBody, setSelectedMediaBody] = createSignal<string>('')
// Pagination state
const [pagination, setPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 20,
total: 0,
totalPages: 0
})
// Filter state
const [searchQuery, setSearchQuery] = createSignal('')
/**
* Загрузка списка публикаций
*/
async function loadShouts() {
try {
setLoading(true)
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
`${location.origin}/graphql`,
ADMIN_GET_SHOUTS_QUERY,
{
limit: pagination().limit,
offset: (pagination().page - 1) * pagination().limit
}
)
if (result?.adminGetShouts?.shouts) {
setShouts(result.adminGetShouts.shouts)
setPagination((prev) => ({
...prev,
total: result.adminGetShouts.total || 0,
totalPages: result.adminGetShouts.totalPages || 1
}))
}
} catch (error) {
console.error('Failed to load shouts:', error)
props.onError?.(error instanceof Error ? error.message : 'Failed to load shouts')
} finally {
setLoading(false)
}
}
// Load shouts on mount
onMount(() => {
void loadShouts()
})
// Pagination handlers
function handlePageChange(page: number) {
setPagination((prev) => ({ ...prev, page }))
void loadShouts()
}
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
void loadShouts()
}
// Helper functions
function getShoutStatus(shout: Shout): string {
if (shout.deleted_at) return '🗑️'
if (shout.published_at) return '✅'
return '📝'
}
function getShoutStatusTitle(shout: Shout): string {
if (shout.deleted_at) return 'Удалена'
if (shout.published_at) return 'Опубликована'
return 'Черновик'
}
function getShoutStatusClass(shout: Shout): string {
if (shout.deleted_at) return 'status-deleted'
if (shout.published_at) return 'status-published'
return 'status-draft'
}
function truncateText(text: string, maxLength = 100): string {
if (!text || text.length <= maxLength) return text
return `${text.substring(0, maxLength)}...`
}
return (
<div class={styles['shouts-container']}>
<Show when={loading()}>
<div class={styles['loading']}>Загрузка публикаций...</div>
</Show>
<Show when={!loading() && shouts().length === 0}>
<div class={styles['empty-state']}>Нет публикаций для отображения</div>
</Show>
<Show when={!loading() && shouts().length > 0}>
<div class={styles['shouts-controls']}>
<div class={styles['search-container']}>
<div class={styles['search-input-group']}>
<input
type="text"
placeholder="Поиск по заголовку, slug или ID..."
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void loadShouts()
}
}}
class={styles['search-input']}
/>
<button class={styles['search-button']} onClick={() => void loadShouts()}>
Поиск
</button>
</div>
</div>
</div>
<div class={styles['shouts-list']}>
<table>
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Slug</th>
<th>Статус</th>
<th>Авторы</th>
<th>Темы</th>
<th>Создан</th>
<th>Содержимое</th>
<th>Media</th>
</tr>
</thead>
<tbody>
<For each={shouts()}>
{(shout) => (
<tr>
<td>{shout.id}</td>
<td title={shout.title}>{truncateText(shout.title, 50)}</td>
<td title={shout.slug}>{truncateText(shout.slug, 30)}</td>
<td>
<span
class={`${styles['status-badge']} ${getShoutStatusClass(shout)}`}
title={getShoutStatusTitle(shout)}
>
{getShoutStatus(shout)}
</span>
</td>
<td>
<Show when={shout.authors?.length}>
<div class={styles['authors-list']}>
<For each={shout.authors}>
{(author) => (
<Show when={author}>
{(safeAuthor) => (
<span class={styles['author-badge']} title={safeAuthor()?.email || ''}>
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
</span>
)}
</Show>
)}
</For>
</div>
</Show>
<Show when={!shout.authors?.length}>
<span class={styles['no-data']}>-</span>
</Show>
</td>
<td>
<Show when={shout.topics?.length}>
<div class={styles['topics-list']}>
<For each={shout.topics}>
{(topic) => (
<Show when={topic}>
{(safeTopic) => (
<span class={styles['topic-badge']} title={safeTopic()?.slug || ''}>
{safeTopic()?.title || safeTopic()?.slug}
</span>
)}
</Show>
)}
</For>
</div>
</Show>
<Show when={!shout.topics?.length}>
<span class={styles['no-data']}>-</span>
</Show>
</td>
<td>{formatDateRelative(shout.created_at)}</td>
<td
class={styles['body-cell']}
onClick={() => {
setSelectedShoutBody(shout.body)
setShowBodyModal(true)
}}
style="cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
>
{truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)}
</td>
<td>
<Show when={shout.media && shout.media.length > 0}>
<div style="display: flex; flex-direction: column; gap: 4px;">
<For each={shout.media}>
{(mediaItem, idx) => (
<div style="display: flex; align-items: center; gap: 6px;">
<span class={styles['media-count']}>
{mediaItem?.title || `media[${idx()}]`}
</span>
<Show when={mediaItem?.body}>
<button
class={styles['edit-button']}
style="padding: 2px 8px; font-size: 12px;"
title="Показать содержимое body"
onClick={() => {
setSelectedMediaBody(mediaItem?.body || '')
setShowMediaBodyModal(true)
}}
>
👁 body
</button>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={!shout.media || shout.media.length === 0}>
<span class={styles['no-data']}>-</span>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
<Pagination
currentPage={pagination().page}
totalPages={pagination().totalPages}
total={pagination().total}
limit={pagination().limit}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
/>
</div>
</Show>
<Modal isOpen={showBodyModal()} onClose={() => setShowBodyModal(false)} title="Содержимое публикации">
<EditableCodePreview
content={selectedShoutBody()}
maxHeight="70vh"
onContentChange={(newContent) => {
setSelectedShoutBody(newContent)
}}
onSave={(_content) => {
// FIXME: добавить логику сохранения изменений в базу данных
props.onSuccess?.('Содержимое публикации обновлено')
setShowBodyModal(false)
}}
onCancel={() => {
setShowBodyModal(false)
}}
placeholder="Введите содержимое публикации..."
/>
</Modal>
<Modal
isOpen={showMediaBodyModal()}
onClose={() => setShowMediaBodyModal(false)}
title="Содержимое media.body"
>
<EditableCodePreview
content={selectedMediaBody()}
maxHeight="70vh"
onContentChange={(newContent) => {
setSelectedMediaBody(newContent)
}}
onSave={(_content) => {
// FIXME: добавить логику сохранения изменений media.body
props.onSuccess?.('Содержимое media.body обновлено')
setShowMediaBodyModal(false)
}}
onCancel={() => {
setShowMediaBodyModal(false)
}}
placeholder="Введите содержимое media.body..."
/>
</Modal>
</div>
)
}
export default ShoutsRoute

410
panel/routes/topics.tsx Normal file
View File

@@ -0,0 +1,410 @@
/**
* Компонент управления топиками
* @module TopicsRoute
*/
import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js'
import { query } from '../graphql'
import type { Query } from '../graphql/generated/schema'
import { DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
import { GET_TOPICS_QUERY } from '../graphql/queries'
import TopicEditModal from '../modals/TopicEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
/**
* Интерфейс топика
*/
interface Topic {
id: number
slug: string
title: string
body?: string
pic?: string
community: number
parent_ids?: number[]
children?: Topic[]
level?: number
}
/**
* Интерфейс свойств компонента
*/
interface TopicsRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
/**
* Компонент управления топиками
*/
const TopicsRoute: Component<TopicsRouteProps> = (props) => {
const [rawTopics, setRawTopics] = createSignal<Topic[]>([])
const [topics, setTopics] = createSignal<Topic[]>([])
const [loading, setLoading] = createSignal(false)
const [sortBy, setSortBy] = createSignal<'id' | 'title'>('id')
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc')
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; topic: Topic | null }>({
show: false,
topic: null
})
const [editModal, setEditModal] = createSignal<{ show: boolean; topic: Topic | null }>({
show: false,
topic: null
})
/**
* Загружает список всех топиков
*/
const loadTopics = async () => {
setLoading(true)
try {
const data = await query<{ get_topics_all: Query['get_topics_all'] }>(
`${location.origin}/graphql`,
GET_TOPICS_QUERY
)
if (data?.get_topics_all) {
// Строим иерархическую структуру
const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null)
setRawTopics(validTopics)
}
} catch (error) {
props.onError(`Ошибка загрузки топиков: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
// Пересортировка при изменении rawTopics или параметров сортировки
createEffect(
on([rawTopics, sortBy, sortDirection], () => {
const rawData = rawTopics()
const sort = sortBy()
const direction = sortDirection()
if (rawData.length > 0) {
// Используем untrack для чтения buildHierarchy без дополнительных зависимостей
const hierarchicalTopics = untrack(() => buildHierarchy(rawData, sort, direction))
setTopics(hierarchicalTopics)
} else {
setTopics([])
}
})
)
// Загружаем топики при монтировании компонента
onMount(() => {
void loadTopics()
})
/**
* Строит иерархическую структуру топиков
*/
const buildHierarchy = (
flatTopics: Topic[],
sortField?: 'id' | 'title',
sortDir?: 'asc' | 'desc'
): Topic[] => {
const topicMap = new Map<number, Topic>()
const rootTopics: Topic[] = []
// Создаем карту всех топиков
flatTopics.forEach((topic) => {
topicMap.set(topic.id, { ...topic, children: [], level: 0 })
})
// Строим иерархию
flatTopics.forEach((topic) => {
const currentTopic = topicMap.get(topic.id)!
if (!topic.parent_ids || topic.parent_ids.length === 0) {
// Корневой топик
rootTopics.push(currentTopic)
} else {
// Находим родителя и добавляем как дочерний
const parentId = topic.parent_ids[topic.parent_ids.length - 1]
const parent = topicMap.get(parentId)
if (parent) {
currentTopic.level = (parent.level || 0) + 1
parent.children!.push(currentTopic)
} else {
// Если родитель не найден, добавляем как корневой
rootTopics.push(currentTopic)
}
}
})
return sortTopics(rootTopics, sortField, sortDir)
}
/**
* Сортирует топики рекурсивно
*/
const sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => {
const field = sortField || sortBy()
const direction = sortDir || sortDirection()
const sortedTopics = topics.sort((a, b) => {
let comparison = 0
if (field === 'title') {
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
} else {
comparison = a.id - b.id
}
return direction === 'desc' ? -comparison : comparison
})
// Рекурсивно сортируем дочерние элементы
sortedTopics.forEach((topic) => {
if (topic.children && topic.children.length > 0) {
topic.children = sortTopics(topic.children, field, direction)
}
})
return sortedTopics
}
/**
* Обрезает текст до указанной длины
*/
const truncateText = (text: string, maxLength = 100): string => {
if (!text) return '—'
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
}
/**
* Рекурсивно отображает топики с отступами для иерархии
*/
const renderTopics = (topics: Topic[]): JSX.Element[] => {
const result: JSX.Element[] = []
topics.forEach((topic) => {
result.push(
<tr
onClick={() => setEditModal({ show: true, topic })}
style={{ cursor: 'pointer' }}
class={styles['clickable-row']}
>
<td>{topic.id}</td>
<td style={{ 'padding-left': `${(topic.level || 0) * 20}px` }}>
{topic.level! > 0 && '└─ '}
{topic.title}
</td>
<td>{topic.slug}</td>
<td>
<div
style={{
'max-width': '200px',
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap'
}}
title={topic.body}
>
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
</div>
</td>
<td>{topic.community}</td>
<td>{topic.parent_ids?.join(', ') || '—'}</td>
<td onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation()
setDeleteModal({ show: true, topic })
}}
class={styles['delete-button']}
title="Удалить топик"
aria-label="Удалить топик"
>
×
</button>
</td>
</tr>
)
if (topic.children && topic.children.length > 0) {
result.push(...renderTopics(topic.children))
}
})
return result
}
/**
* Обновляет топик
*/
const updateTopic = async (updatedTopic: Topic) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: UPDATE_TOPIC_MUTATION,
variables: { topic_input: updatedTopic }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.update_topic.success) {
props.onSuccess('Топик успешно обновлен')
setEditModal({ show: false, topic: null })
await loadTopics() // Перезагружаем список
} else {
throw new Error(result.data.update_topic.message || 'Ошибка обновления топика')
}
} catch (error) {
props.onError(`Ошибка обновления топика: ${(error as Error).message}`)
}
}
/**
* Удаляет топик
*/
const deleteTopic = async (topicId: number) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: DELETE_TOPIC_MUTATION,
variables: { id: topicId }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.delete_topic_by_id.success) {
props.onSuccess('Топик успешно удален')
setDeleteModal({ show: false, topic: null })
await loadTopics() // Перезагружаем список
} else {
throw new Error(result.data.delete_topic_by_id.message || 'Ошибка удаления топика')
}
} catch (error) {
props.onError(`Ошибка удаления топика: ${(error as Error).message}`)
}
}
return (
<div class={styles.container}>
<div class={styles.header}>
<h2>Управление топиками</h2>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
<select
value={sortBy()}
onInput={(e) => setSortBy(e.target.value as 'id' | 'title')}
style={{
padding: '4px 8px',
border: '1px solid #ddd',
'border-radius': '4px',
'font-size': '14px'
}}
>
<option value="id">По ID</option>
<option value="title">По названию</option>
</select>
<select
value={sortDirection()}
onInput={(e) => setSortDirection(e.target.value as 'asc' | 'desc')}
style={{
padding: '4px 8px',
border: '1px solid #ddd',
'border-radius': '4px',
'font-size': '14px'
}}
>
<option value="asc"> По возрастанию</option>
<option value="desc"> По убыванию</option>
</select>
</div>
<Button onClick={loadTopics} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
</div>
</div>
<Show
when={!loading()}
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка топиков...</div>
</div>
}
>
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<th>Описание</th>
<th>Сообщество</th>
<th>Родители</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={renderTopics(topics())}>{(row) => row}</For>
</tbody>
</table>
</Show>
{/* Модальное окно редактирования */}
<TopicEditModal
isOpen={editModal().show}
topic={editModal().topic}
onClose={() => setEditModal({ show: false, topic: null })}
onSave={updateTopic}
/>
{/* Модальное окно подтверждения удаления */}
<Modal
isOpen={deleteModal().show}
onClose={() => setDeleteModal({ show: false, topic: null })}
title="Подтверждение удаления"
>
<div>
<p>
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
</p>
<p class={styles['warning-text']}>
Это действие нельзя отменить. Все дочерние топики также будут удалены.
</p>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
Отмена
</Button>
<Button
variant="danger"
onClick={() => deleteModal().topic && deleteTopic(deleteModal().topic!.id)}
>
Удалить
</Button>
</div>
</div>
</Modal>
</div>
)
}
export default TopicsRoute