0.5.8-panel-upgrade-community-crud-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s
All checks were successful
Deploy on push / deploy (push) Successful in 6s
This commit is contained in:
283
panel/routes/authors.tsx
Normal file
283
panel/routes/authors.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { Query, AdminUserInfo as User } from '../graphql/generated/schema'
|
||||
import { ADMIN_UPDATE_USER_MUTATION } from '../graphql/mutations'
|
||||
import { ADMIN_GET_USERS_QUERY } from '../graphql/queries'
|
||||
import UserEditModal from '../modals/RolesModal'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
import Pagination from '../ui/Pagination'
|
||||
import { formatDateRelative } from '../utils/date'
|
||||
|
||||
export interface AuthorsRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
const AuthorsRoute: Component<AuthorsRouteProps> = (props) => {
|
||||
console.log('[AuthorsRoute] Initializing...')
|
||||
const [authors, setUsers] = createSignal<User[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
|
||||
const [showEditModal, setShowEditModal] = createSignal(false)
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = createSignal<{
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
})
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
/**
|
||||
* Загрузка списка пользователей с учетом пагинации и поиска
|
||||
*/
|
||||
async function loadUsers() {
|
||||
console.log('[AuthorsRoute] Loading authors...')
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await query<{ adminGetUsers: Query['adminGetUsers'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_USERS_QUERY,
|
||||
{
|
||||
search: searchQuery(),
|
||||
limit: pagination().limit,
|
||||
offset: (pagination().page - 1) * pagination().limit
|
||||
}
|
||||
)
|
||||
if (data?.adminGetUsers?.authors) {
|
||||
console.log('[AuthorsRoute] Users loaded:', data.adminGetUsers.authors.length)
|
||||
setUsers(data.adminGetUsers.authors)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: data.adminGetUsers.total || 0,
|
||||
totalPages: data.adminGetUsers.totalPages || 1
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthorsRoute] Failed to load authors:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Failed to load authors')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет данные пользователя (профиль и роли)
|
||||
*/
|
||||
async function updateUser(userData: {
|
||||
id: number
|
||||
email?: string
|
||||
name?: string
|
||||
slug?: string
|
||||
roles: string[]
|
||||
}) {
|
||||
try {
|
||||
await query(`${location.origin}/graphql`, ADMIN_UPDATE_USER_MUTATION, {
|
||||
user: userData
|
||||
})
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((user) => {
|
||||
if (user.id === userData.id) {
|
||||
return {
|
||||
...user,
|
||||
email: userData.email || user.email,
|
||||
name: userData.name || user.name,
|
||||
slug: userData.slug || user.slug,
|
||||
roles: userData.roles
|
||||
}
|
||||
}
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
closeEditModal()
|
||||
props.onSuccess?.('Данные пользователя успешно обновлены')
|
||||
void loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления пользователя:', err)
|
||||
let errorMessage = err instanceof Error ? err.message : 'Ошибка обновления данных пользователя'
|
||||
|
||||
if (errorMessage.includes('author_role.community')) {
|
||||
errorMessage = 'Ошибка: для роли author требуется указать community. Обратитесь к администратору.'
|
||||
}
|
||||
|
||||
props.onError?.(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
setShowEditModal(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
|
||||
// Pagination handlers
|
||||
function handlePageChange(page: number) {
|
||||
setPagination((prev) => ({ ...prev, page }))
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function handlePerPageChange(limit: number) {
|
||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
// Search handlers
|
||||
function handleSearchChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
setSearchQuery(input.value)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
setPagination((prev) => ({ ...prev, page: 1 }))
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
// Load authors on mount
|
||||
onMount(() => {
|
||||
console.log('[AuthorsRoute] Component mounted, loading authors...')
|
||||
void loadUsers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Компонент для отображения роли с иконкой
|
||||
*/
|
||||
const RoleBadge: Component<{ role: string }> = (props) => {
|
||||
const getRoleIcon = (role: string): string => {
|
||||
switch (role.toLowerCase()) {
|
||||
case 'admin':
|
||||
return '👑'
|
||||
case 'editor':
|
||||
return '✏️'
|
||||
case 'expert':
|
||||
return '🎓'
|
||||
case 'author':
|
||||
return '📝'
|
||||
case 'reader':
|
||||
return '👤'
|
||||
case 'banned':
|
||||
return '🚫'
|
||||
case 'verified':
|
||||
return '✓'
|
||||
default:
|
||||
return '👤'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span class="role-badge" title={props.role}>
|
||||
<span class="role-icon">{getRoleIcon(props.role)}</span>
|
||||
<span class="role-name">{props.role}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['authors-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка данных...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && authors().length === 0}>
|
||||
<div class={styles['empty-state']}>Нет данных для отображения</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && authors().length > 0}>
|
||||
<div class={styles['authors-controls']}>
|
||||
<div class={styles['search-container']}>
|
||||
<div class={styles['search-input-group']}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по email, имени или ID..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
class={styles['search-input']}
|
||||
/>
|
||||
<button class={styles['search-button']} onClick={handleSearch}>
|
||||
Поиск
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles['authors-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Имя</th>
|
||||
<th>Создан</th>
|
||||
<th>Роли</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={authors()}>
|
||||
{(user) => (
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || '-'}</td>
|
||||
<td>{formatDateRelative(user.created_at || Date.now())}</td>
|
||||
<td class={styles['roles-cell']}>
|
||||
<div class={styles['roles-container']}>
|
||||
<For each={Array.from(user.roles || []).filter(Boolean)}>
|
||||
{(role) => <RoleBadge role={role} />}
|
||||
</For>
|
||||
<div
|
||||
class={styles['role-badge edit-role-badge']}
|
||||
onClick={() => {
|
||||
setSelectedUser(user)
|
||||
setShowEditModal(true)
|
||||
}}
|
||||
>
|
||||
<span class={styles['role-icon']}>🎭</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination().page}
|
||||
totalPages={pagination().totalPages}
|
||||
total={pagination().total}
|
||||
limit={pagination().limit}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={showEditModal() && selectedUser()}>
|
||||
<UserEditModal
|
||||
user={selectedUser()!}
|
||||
isOpen={showEditModal()}
|
||||
onClose={closeEditModal}
|
||||
onSave={updateUser}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorsRoute
|
381
panel/routes/communities.tsx
Normal file
381
panel/routes/communities.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { DELETE_COMMUNITY_MUTATION, UPDATE_COMMUNITY_MUTATION } from '../graphql/mutations'
|
||||
import { GET_COMMUNITIES_QUERY } from '../graphql/queries'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
/**
|
||||
* Интерфейс для сообщества (используем локальный интерфейс для совместимости)
|
||||
*/
|
||||
interface Community {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
desc?: string
|
||||
pic: string
|
||||
created_at: number
|
||||
created_by: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
stat: {
|
||||
shouts: number
|
||||
followers: number
|
||||
authors: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CommunitiesRouteProps {
|
||||
onError: (error: string) => void
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для управления сообществами
|
||||
*/
|
||||
const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||
const [communities, setCommunities] = createSignal<Community[]>([])
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [editModal, setEditModal] = createSignal<{ show: boolean; community: Community | null }>({
|
||||
show: false,
|
||||
community: null
|
||||
})
|
||||
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; community: Community | null }>({
|
||||
show: false,
|
||||
community: null
|
||||
})
|
||||
|
||||
// Форма для редактирования
|
||||
const [formData, setFormData] = createSignal({
|
||||
slug: '',
|
||||
name: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Загружает список всех сообществ
|
||||
*/
|
||||
const loadCommunities = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: GET_COMMUNITIES_QUERY
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
setCommunities(result.data.get_communities_all || [])
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка загрузки сообществ: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату
|
||||
*/
|
||||
const formatDate = (timestamp: number): string => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модалку редактирования
|
||||
*/
|
||||
const openEditModal = (community: Community) => {
|
||||
setFormData({
|
||||
slug: community.slug,
|
||||
name: community.name,
|
||||
desc: community.desc || '',
|
||||
pic: community.pic
|
||||
})
|
||||
setEditModal({ show: true, community })
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет сообщество
|
||||
*/
|
||||
const updateCommunity = async () => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: UPDATE_COMMUNITY_MUTATION,
|
||||
variables: { community_input: formData() }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.update_community.error) {
|
||||
throw new Error(result.data.update_community.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Сообщество успешно обновлено')
|
||||
setEditModal({ show: false, community: null })
|
||||
await loadCommunities()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка обновления сообщества: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет сообщество
|
||||
*/
|
||||
const deleteCommunity = async (slug: string) => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: DELETE_COMMUNITY_MUTATION,
|
||||
variables: { slug }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.delete_community.error) {
|
||||
throw new Error(result.data.delete_community.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Сообщество успешно удалено')
|
||||
setDeleteModal({ show: false, community: null })
|
||||
await loadCommunities()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка удаления сообщества: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем сообщества при монтировании компонента
|
||||
onMount(() => {
|
||||
void loadCommunities()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление сообществами</h2>
|
||||
<Button onClick={loadCommunities} disabled={loading()}>
|
||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Загрузка сообществ...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table class={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Slug</th>
|
||||
<th>Описание</th>
|
||||
<th>Создатель</th>
|
||||
<th>Публикации</th>
|
||||
<th>Подписчики</th>
|
||||
<th>Авторы</th>
|
||||
<th>Создано</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={communities()}>
|
||||
{(community) => (
|
||||
<tr
|
||||
onClick={() => openEditModal(community)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
class={styles['clickable-row']}
|
||||
>
|
||||
<td>{community.id}</td>
|
||||
<td>{community.name}</td>
|
||||
<td>{community.slug}</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
'max-width': '200px',
|
||||
overflow: 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
'white-space': 'nowrap'
|
||||
}}
|
||||
title={community.desc}
|
||||
>
|
||||
{community.desc || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td>{community.created_by.name || community.created_by.email}</td>
|
||||
<td>{community.stat.shouts}</td>
|
||||
<td>{community.stat.followers}</td>
|
||||
<td>{community.stat.authors}</td>
|
||||
<td>{formatDate(community.created_at)}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteModal({ show: true, community })
|
||||
}}
|
||||
class={styles['delete-button']}
|
||||
title="Удалить сообщество"
|
||||
aria-label="Удалить сообщество"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<Modal
|
||||
isOpen={editModal().show}
|
||||
onClose={() => setEditModal({ show: false, community: null })}
|
||||
title={`Редактирование сообщества: ${editModal().community?.name || ''}`}
|
||||
>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().slug}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().name}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={formData().desc}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px',
|
||||
'min-height': '80px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
placeholder="Описание сообщества..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Картинка (URL)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().pic}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setEditModal({ show: false, community: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={updateCommunity}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Модальное окно подтверждения удаления */}
|
||||
<Modal
|
||||
isOpen={deleteModal().show}
|
||||
onClose={() => setDeleteModal({ show: false, community: null })}
|
||||
title="Подтверждение удаления"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
Вы уверены, что хотите удалить сообщество "<strong>{deleteModal().community?.name}</strong>"?
|
||||
</p>
|
||||
<p class={styles['warning-text']}>
|
||||
Это действие нельзя отменить. Все публикации и темы сообщества могут быть затронуты.
|
||||
</p>
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, community: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteModal().community && deleteCommunity(deleteModal().community!.slug)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommunitiesRoute
|
275
panel/routes/env.tsx
Normal file
275
panel/routes/env.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Component, createSignal, For, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { EnvSection, EnvVariable, Query } from '../graphql/generated/schema'
|
||||
import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations'
|
||||
import { ADMIN_GET_ENV_VARIABLES_QUERY } from '../graphql/queries'
|
||||
import EnvVariableModal from '../modals/EnvVariableModal'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
import Button from '../ui/Button'
|
||||
|
||||
export interface EnvRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
const EnvRoute: Component<EnvRouteProps> = (props) => {
|
||||
const [envSections, setEnvSections] = createSignal<EnvSection[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [editingVariable, setEditingVariable] = createSignal<EnvVariable | null>(null)
|
||||
const [showVariableModal, setShowVariableModal] = createSignal(false)
|
||||
|
||||
// Состояние для показа/скрытия значений
|
||||
const [shownVars, setShownVars] = createSignal<{ [key: string]: boolean }>({})
|
||||
|
||||
/**
|
||||
* Загружает переменные окружения
|
||||
*/
|
||||
const loadEnvVariables = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await query<{ getEnvVariables: Query['getEnvVariables'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_ENV_VARIABLES_QUERY
|
||||
)
|
||||
|
||||
// Важно: пустой массив [] тоже валидный результат!
|
||||
if (result && Array.isArray(result.getEnvVariables)) {
|
||||
setEnvSections(result.getEnvVariables)
|
||||
console.log('Загружено секций переменных:', result.getEnvVariables.length)
|
||||
} else {
|
||||
console.warn('Неожиданный результат от getEnvVariables:', result)
|
||||
setEnvSections([]) // Устанавливаем пустой массив если что-то пошло не так
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load env variables:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Failed to load environment variables')
|
||||
setEnvSections([]) // Устанавливаем пустой массив при ошибке
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет значение переменной окружения
|
||||
*/
|
||||
const updateEnvVariable = async (key: string, value: string) => {
|
||||
try {
|
||||
const result = await query(`${location.origin}/graphql`, ADMIN_UPDATE_ENV_VARIABLE_MUTATION, {
|
||||
key,
|
||||
value
|
||||
})
|
||||
|
||||
if (result && typeof result === 'object' && 'updateEnvVariable' in result) {
|
||||
props.onSuccess?.(`Переменная ${key} успешно обновлена`)
|
||||
await loadEnvVariables()
|
||||
} else {
|
||||
props.onError?.('Не удалось обновить переменную')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления переменной:', err)
|
||||
props.onError?.(err instanceof Error ? err.message : 'Ошибка при обновлении переменной')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик открытия модального окна редактирования переменной
|
||||
*/
|
||||
const openVariableModal = (variable: EnvVariable) => {
|
||||
setEditingVariable({ ...variable })
|
||||
setShowVariableModal(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик закрытия модального окна редактирования переменной
|
||||
*/
|
||||
const closeVariableModal = () => {
|
||||
setEditingVariable(null)
|
||||
setShowVariableModal(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик сохранения переменной
|
||||
*/
|
||||
const saveVariable = async () => {
|
||||
const variable = editingVariable()
|
||||
if (!variable) return
|
||||
|
||||
await updateEnvVariable(variable.key, variable.value)
|
||||
closeVariableModal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик изменения значения в модальном окне
|
||||
*/
|
||||
const handleVariableValueChange = (value: string) => {
|
||||
const variable = editingVariable()
|
||||
if (variable) {
|
||||
setEditingVariable({ ...variable, value })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает показ значения переменной
|
||||
*/
|
||||
const toggleShow = (key: string) => {
|
||||
setShownVars((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Копирует значение в буфер обмена
|
||||
*/
|
||||
const CopyButton: Component<{ value: string }> = (props) => {
|
||||
const handleCopy = async (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value)
|
||||
// Можно добавить всплывающее уведомление
|
||||
} catch (err) {
|
||||
alert(`Ошибка копирования: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<a class="btn" title="Скопировать" type="button" style="margin-left: 6px" onClick={handleCopy}>
|
||||
📋
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка показать/скрыть значение переменной
|
||||
*/
|
||||
const ShowHideButton: Component<{ shown: boolean; onToggle: () => void }> = (props) => {
|
||||
return (
|
||||
<a
|
||||
class="btn"
|
||||
title={props.shown ? 'Скрыть' : 'Показать'}
|
||||
type="button"
|
||||
style="margin-left: 6px"
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
{props.shown ? '🙈' : '👁️'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// Load env variables on mount
|
||||
void loadEnvVariables()
|
||||
|
||||
// ВРЕМЕННО: для тестирования пустого состояния
|
||||
// setTimeout(() => {
|
||||
// setLoading(false)
|
||||
// setEnvSections([])
|
||||
// console.log('Тест: установлено пустое состояние')
|
||||
// }, 1000)
|
||||
|
||||
return (
|
||||
<div class={styles['env-variables-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка переменных окружения...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && envSections().length === 0}>
|
||||
<div class={styles['empty-state']}>
|
||||
<h3>Переменные окружения не найдены</h3>
|
||||
<p>
|
||||
Переменные окружения не настроены или не обнаружены в системе.
|
||||
<br />
|
||||
Вы можете добавить переменные через файл <code>.env</code> или системные переменные.
|
||||
</p>
|
||||
<details style="margin-top: 16px;">
|
||||
<summary style="cursor: pointer; font-weight: 600;">Как добавить переменные?</summary>
|
||||
<div style="margin-top: 8px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
|
||||
<p>
|
||||
<strong>Способ 1:</strong> Через командную строку
|
||||
</p>
|
||||
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
|
||||
export DEBUG=true export DB_URL="postgresql://localhost:5432/db" export
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
</pre>
|
||||
|
||||
<p style="margin-top: 12px;">
|
||||
<strong>Способ 2:</strong> Через файл .env
|
||||
</p>
|
||||
<pre style="background: #e9ecef; padding: 8px; border-radius: 4px; font-size: 12px;">
|
||||
DEBUG=true DB_URL=postgresql://localhost:5432/db REDIS_URL=redis://localhost:6379
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && envSections().length > 0}>
|
||||
<div class={styles['env-sections']}>
|
||||
<For each={envSections()}>
|
||||
{(section) => (
|
||||
<div class={styles['env-section']}>
|
||||
<h3 class={styles['section-name']}>{section.name}</h3>
|
||||
<Show when={section.description}>
|
||||
<p class={styles['section-description']}>{section.description}</p>
|
||||
</Show>
|
||||
<div class={styles['variables-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ключ</th>
|
||||
<th>Значение</th>
|
||||
<th>Описание</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={section.variables}>
|
||||
{(variable) => {
|
||||
const shown = () => shownVars()[variable.key] || false
|
||||
return (
|
||||
<tr>
|
||||
<td>{variable.key}</td>
|
||||
<td>
|
||||
{variable.isSecret && !shown()
|
||||
? '••••••••'
|
||||
: variable.value || <span class={styles['empty-value']}>не задано</span>}
|
||||
<CopyButton value={variable.value || ''} />
|
||||
{variable.isSecret && (
|
||||
<ShowHideButton
|
||||
shown={shown()}
|
||||
onToggle={() => toggleShow(variable.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>{variable.description || '-'}</td>
|
||||
<td class={styles['actions']}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => openVariableModal(variable)}
|
||||
>
|
||||
Изменить
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={editingVariable()}>
|
||||
<EnvVariableModal
|
||||
isOpen={showVariableModal()}
|
||||
variable={editingVariable()!}
|
||||
onClose={closeVariableModal}
|
||||
onSave={saveVariable}
|
||||
onValueChange={handleVariableValueChange}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnvRoute
|
89
panel/routes/login.tsx
Normal file
89
panel/routes/login.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
* @module LoginPage
|
||||
*/
|
||||
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
import publyLogo from '../assets/publy.svg?url'
|
||||
import { useAuth } from '../context/auth'
|
||||
import styles from '../styles/Login.module.css'
|
||||
import Button from '../ui/Button'
|
||||
|
||||
/**
|
||||
* Компонент страницы входа
|
||||
*/
|
||||
const LoginPage = () => {
|
||||
console.log('[LoginPage] Initializing...')
|
||||
const [username, setUsername] = createSignal('')
|
||||
const [password, setPassword] = createSignal('')
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const auth = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
onMount(() => {
|
||||
console.log('[LoginPage] Component mounted')
|
||||
// Если пользователь уже авторизован, редиректим на админ-панель
|
||||
if (auth.isAuthenticated()) {
|
||||
console.log('[LoginPage] User already authenticated, redirecting to admin...')
|
||||
navigate('/admin')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await auth.login(username(), password())
|
||||
navigate('/admin')
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Ошибка при входе')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['login-container']}>
|
||||
<form class={styles['login-form']} onSubmit={handleSubmit}>
|
||||
<img src={publyLogo} alt="Logo" class={styles['login-logo']} />
|
||||
<h1>Вход в панель администратора</h1>
|
||||
|
||||
{error() && <div class={styles['error-message']}>{error()}</div>}
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username()}
|
||||
onInput={(e) => setUsername(e.currentTarget.value)}
|
||||
disabled={loading()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles['form-group']}>
|
||||
<label for="password">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
disabled={loading()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={loading()} loading={loading()}>
|
||||
{loading() ? 'Вход...' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
317
panel/routes/shouts.tsx
Normal file
317
panel/routes/shouts.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { Query, AdminShoutInfo as Shout } from '../graphql/generated/schema'
|
||||
import { ADMIN_GET_SHOUTS_QUERY } from '../graphql/queries'
|
||||
import styles from '../styles/Admin.module.css'
|
||||
import EditableCodePreview from '../ui/EditableCodePreview'
|
||||
import Modal from '../ui/Modal'
|
||||
import Pagination from '../ui/Pagination'
|
||||
import { formatDateRelative } from '../utils/date'
|
||||
|
||||
export interface ShoutsRouteProps {
|
||||
onError?: (error: string) => void
|
||||
onSuccess?: (message: string) => void
|
||||
}
|
||||
|
||||
const ShoutsRoute: Component<ShoutsRouteProps> = (props) => {
|
||||
const [shouts, setShouts] = createSignal<Shout[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [showBodyModal, setShowBodyModal] = createSignal(false)
|
||||
const [selectedShoutBody, setSelectedShoutBody] = createSignal<string>('')
|
||||
const [showMediaBodyModal, setShowMediaBodyModal] = createSignal(false)
|
||||
const [selectedMediaBody, setSelectedMediaBody] = createSignal<string>('')
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = createSignal<{
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
/**
|
||||
* Загрузка списка публикаций
|
||||
*/
|
||||
async function loadShouts() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await query<{ adminGetShouts: Query['adminGetShouts'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
ADMIN_GET_SHOUTS_QUERY,
|
||||
{
|
||||
limit: pagination().limit,
|
||||
offset: (pagination().page - 1) * pagination().limit
|
||||
}
|
||||
)
|
||||
if (result?.adminGetShouts?.shouts) {
|
||||
setShouts(result.adminGetShouts.shouts)
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: result.adminGetShouts.total || 0,
|
||||
totalPages: result.adminGetShouts.totalPages || 1
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load shouts:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Failed to load shouts')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load shouts on mount
|
||||
onMount(() => {
|
||||
void loadShouts()
|
||||
})
|
||||
|
||||
// Pagination handlers
|
||||
function handlePageChange(page: number) {
|
||||
setPagination((prev) => ({ ...prev, page }))
|
||||
void loadShouts()
|
||||
}
|
||||
|
||||
function handlePerPageChange(limit: number) {
|
||||
setPagination((prev) => ({ ...prev, page: 1, limit }))
|
||||
void loadShouts()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getShoutStatus(shout: Shout): string {
|
||||
if (shout.deleted_at) return '🗑️'
|
||||
if (shout.published_at) return '✅'
|
||||
return '📝'
|
||||
}
|
||||
|
||||
function getShoutStatusTitle(shout: Shout): string {
|
||||
if (shout.deleted_at) return 'Удалена'
|
||||
if (shout.published_at) return 'Опубликована'
|
||||
return 'Черновик'
|
||||
}
|
||||
|
||||
function getShoutStatusClass(shout: Shout): string {
|
||||
if (shout.deleted_at) return 'status-deleted'
|
||||
if (shout.published_at) return 'status-published'
|
||||
return 'status-draft'
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength = 100): string {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return `${text.substring(0, maxLength)}...`
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles['shouts-container']}>
|
||||
<Show when={loading()}>
|
||||
<div class={styles['loading']}>Загрузка публикаций...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && shouts().length === 0}>
|
||||
<div class={styles['empty-state']}>Нет публикаций для отображения</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && shouts().length > 0}>
|
||||
<div class={styles['shouts-controls']}>
|
||||
<div class={styles['search-container']}>
|
||||
<div class={styles['search-input-group']}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по заголовку, slug или ID..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
void loadShouts()
|
||||
}
|
||||
}}
|
||||
class={styles['search-input']}
|
||||
/>
|
||||
<button class={styles['search-button']} onClick={() => void loadShouts()}>
|
||||
Поиск
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles['shouts-list']}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Заголовок</th>
|
||||
<th>Slug</th>
|
||||
<th>Статус</th>
|
||||
<th>Авторы</th>
|
||||
<th>Темы</th>
|
||||
<th>Создан</th>
|
||||
<th>Содержимое</th>
|
||||
<th>Media</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={shouts()}>
|
||||
{(shout) => (
|
||||
<tr>
|
||||
<td>{shout.id}</td>
|
||||
<td title={shout.title}>{truncateText(shout.title, 50)}</td>
|
||||
<td title={shout.slug}>{truncateText(shout.slug, 30)}</td>
|
||||
<td>
|
||||
<span
|
||||
class={`${styles['status-badge']} ${getShoutStatusClass(shout)}`}
|
||||
title={getShoutStatusTitle(shout)}
|
||||
>
|
||||
{getShoutStatus(shout)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={shout.authors?.length}>
|
||||
<div class={styles['authors-list']}>
|
||||
<For each={shout.authors}>
|
||||
{(author) => (
|
||||
<Show when={author}>
|
||||
{(safeAuthor) => (
|
||||
<span class={styles['author-badge']} title={safeAuthor()?.email || ''}>
|
||||
{safeAuthor()?.name || safeAuthor()?.email || `ID:${safeAuthor()?.id}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!shout.authors?.length}>
|
||||
<span class={styles['no-data']}>-</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={shout.topics?.length}>
|
||||
<div class={styles['topics-list']}>
|
||||
<For each={shout.topics}>
|
||||
{(topic) => (
|
||||
<Show when={topic}>
|
||||
{(safeTopic) => (
|
||||
<span class={styles['topic-badge']} title={safeTopic()?.slug || ''}>
|
||||
{safeTopic()?.title || safeTopic()?.slug}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!shout.topics?.length}>
|
||||
<span class={styles['no-data']}>-</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>{formatDateRelative(shout.created_at)}</td>
|
||||
<td
|
||||
class={styles['body-cell']}
|
||||
onClick={() => {
|
||||
setSelectedShoutBody(shout.body)
|
||||
setShowBodyModal(true)
|
||||
}}
|
||||
style="cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
>
|
||||
{truncateText(shout.body.replace(/<[^>]*>/g, ''), 100)}
|
||||
</td>
|
||||
<td>
|
||||
<Show when={shout.media && shout.media.length > 0}>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<For each={shout.media}>
|
||||
{(mediaItem, idx) => (
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<span class={styles['media-count']}>
|
||||
{mediaItem?.title || `media[${idx()}]`}
|
||||
</span>
|
||||
<Show when={mediaItem?.body}>
|
||||
<button
|
||||
class={styles['edit-button']}
|
||||
style="padding: 2px 8px; font-size: 12px;"
|
||||
title="Показать содержимое body"
|
||||
onClick={() => {
|
||||
setSelectedMediaBody(mediaItem?.body || '')
|
||||
setShowMediaBodyModal(true)
|
||||
}}
|
||||
>
|
||||
👁 body
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!shout.media || shout.media.length === 0}>
|
||||
<span class={styles['no-data']}>-</span>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination().page}
|
||||
totalPages={pagination().totalPages}
|
||||
total={pagination().total}
|
||||
limit={pagination().limit}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal isOpen={showBodyModal()} onClose={() => setShowBodyModal(false)} title="Содержимое публикации">
|
||||
<EditableCodePreview
|
||||
content={selectedShoutBody()}
|
||||
maxHeight="70vh"
|
||||
onContentChange={(newContent) => {
|
||||
setSelectedShoutBody(newContent)
|
||||
}}
|
||||
onSave={(_content) => {
|
||||
// FIXME: добавить логику сохранения изменений в базу данных
|
||||
props.onSuccess?.('Содержимое публикации обновлено')
|
||||
setShowBodyModal(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowBodyModal(false)
|
||||
}}
|
||||
placeholder="Введите содержимое публикации..."
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showMediaBodyModal()}
|
||||
onClose={() => setShowMediaBodyModal(false)}
|
||||
title="Содержимое media.body"
|
||||
>
|
||||
<EditableCodePreview
|
||||
content={selectedMediaBody()}
|
||||
maxHeight="70vh"
|
||||
onContentChange={(newContent) => {
|
||||
setSelectedMediaBody(newContent)
|
||||
}}
|
||||
onSave={(_content) => {
|
||||
// FIXME: добавить логику сохранения изменений media.body
|
||||
props.onSuccess?.('Содержимое media.body обновлено')
|
||||
setShowMediaBodyModal(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMediaBodyModal(false)
|
||||
}}
|
||||
placeholder="Введите содержимое media.body..."
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShoutsRoute
|
410
panel/routes/topics.tsx
Normal file
410
panel/routes/topics.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Компонент управления топиками
|
||||
* @module TopicsRoute
|
||||
*/
|
||||
|
||||
import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js'
|
||||
import { query } from '../graphql'
|
||||
import type { Query } from '../graphql/generated/schema'
|
||||
import { DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||
import TopicEditModal from '../modals/TopicEditModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
/**
|
||||
* Интерфейс топика
|
||||
*/
|
||||
interface Topic {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
body?: string
|
||||
pic?: string
|
||||
community: number
|
||||
parent_ids?: number[]
|
||||
children?: Topic[]
|
||||
level?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс свойств компонента
|
||||
*/
|
||||
interface TopicsRouteProps {
|
||||
onError: (error: string) => void
|
||||
onSuccess: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент управления топиками
|
||||
*/
|
||||
const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
const [rawTopics, setRawTopics] = createSignal<Topic[]>([])
|
||||
const [topics, setTopics] = createSignal<Topic[]>([])
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [sortBy, setSortBy] = createSignal<'id' | 'title'>('id')
|
||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc')
|
||||
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; topic: Topic | null }>({
|
||||
show: false,
|
||||
topic: null
|
||||
})
|
||||
const [editModal, setEditModal] = createSignal<{ show: boolean; topic: Topic | null }>({
|
||||
show: false,
|
||||
topic: null
|
||||
})
|
||||
|
||||
/**
|
||||
* Загружает список всех топиков
|
||||
*/
|
||||
const loadTopics = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await query<{ get_topics_all: Query['get_topics_all'] }>(
|
||||
`${location.origin}/graphql`,
|
||||
GET_TOPICS_QUERY
|
||||
)
|
||||
|
||||
if (data?.get_topics_all) {
|
||||
// Строим иерархическую структуру
|
||||
const validTopics = data.get_topics_all.filter((topic): topic is Topic => topic !== null)
|
||||
setRawTopics(validTopics)
|
||||
}
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка загрузки топиков: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Пересортировка при изменении rawTopics или параметров сортировки
|
||||
createEffect(
|
||||
on([rawTopics, sortBy, sortDirection], () => {
|
||||
const rawData = rawTopics()
|
||||
const sort = sortBy()
|
||||
const direction = sortDirection()
|
||||
|
||||
if (rawData.length > 0) {
|
||||
// Используем untrack для чтения buildHierarchy без дополнительных зависимостей
|
||||
const hierarchicalTopics = untrack(() => buildHierarchy(rawData, sort, direction))
|
||||
setTopics(hierarchicalTopics)
|
||||
} else {
|
||||
setTopics([])
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Загружаем топики при монтировании компонента
|
||||
onMount(() => {
|
||||
void loadTopics()
|
||||
})
|
||||
|
||||
/**
|
||||
* Строит иерархическую структуру топиков
|
||||
*/
|
||||
const buildHierarchy = (
|
||||
flatTopics: Topic[],
|
||||
sortField?: 'id' | 'title',
|
||||
sortDir?: 'asc' | 'desc'
|
||||
): Topic[] => {
|
||||
const topicMap = new Map<number, Topic>()
|
||||
const rootTopics: Topic[] = []
|
||||
|
||||
// Создаем карту всех топиков
|
||||
flatTopics.forEach((topic) => {
|
||||
topicMap.set(topic.id, { ...topic, children: [], level: 0 })
|
||||
})
|
||||
|
||||
// Строим иерархию
|
||||
flatTopics.forEach((topic) => {
|
||||
const currentTopic = topicMap.get(topic.id)!
|
||||
|
||||
if (!topic.parent_ids || topic.parent_ids.length === 0) {
|
||||
// Корневой топик
|
||||
rootTopics.push(currentTopic)
|
||||
} else {
|
||||
// Находим родителя и добавляем как дочерний
|
||||
const parentId = topic.parent_ids[topic.parent_ids.length - 1]
|
||||
const parent = topicMap.get(parentId)
|
||||
if (parent) {
|
||||
currentTopic.level = (parent.level || 0) + 1
|
||||
parent.children!.push(currentTopic)
|
||||
} else {
|
||||
// Если родитель не найден, добавляем как корневой
|
||||
rootTopics.push(currentTopic)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return sortTopics(rootTopics, sortField, sortDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Сортирует топики рекурсивно
|
||||
*/
|
||||
const sortTopics = (topics: Topic[], sortField?: 'id' | 'title', sortDir?: 'asc' | 'desc'): Topic[] => {
|
||||
const field = sortField || sortBy()
|
||||
const direction = sortDir || sortDirection()
|
||||
|
||||
const sortedTopics = topics.sort((a, b) => {
|
||||
let comparison = 0
|
||||
|
||||
if (field === 'title') {
|
||||
comparison = (a.title || '').localeCompare(b.title || '', 'ru')
|
||||
} else {
|
||||
comparison = a.id - b.id
|
||||
}
|
||||
|
||||
return direction === 'desc' ? -comparison : comparison
|
||||
})
|
||||
|
||||
// Рекурсивно сортируем дочерние элементы
|
||||
sortedTopics.forEach((topic) => {
|
||||
if (topic.children && topic.children.length > 0) {
|
||||
topic.children = sortTopics(topic.children, field, direction)
|
||||
}
|
||||
})
|
||||
|
||||
return sortedTopics
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрезает текст до указанной длины
|
||||
*/
|
||||
const truncateText = (text: string, maxLength = 100): string => {
|
||||
if (!text) return '—'
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно отображает топики с отступами для иерархии
|
||||
*/
|
||||
const renderTopics = (topics: Topic[]): JSX.Element[] => {
|
||||
const result: JSX.Element[] = []
|
||||
|
||||
topics.forEach((topic) => {
|
||||
result.push(
|
||||
<tr
|
||||
onClick={() => setEditModal({ show: true, topic })}
|
||||
style={{ cursor: 'pointer' }}
|
||||
class={styles['clickable-row']}
|
||||
>
|
||||
<td>{topic.id}</td>
|
||||
<td style={{ 'padding-left': `${(topic.level || 0) * 20}px` }}>
|
||||
{topic.level! > 0 && '└─ '}
|
||||
{topic.title}
|
||||
</td>
|
||||
<td>{topic.slug}</td>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
'max-width': '200px',
|
||||
overflow: 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
'white-space': 'nowrap'
|
||||
}}
|
||||
title={topic.body}
|
||||
>
|
||||
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{topic.community}</td>
|
||||
<td>{topic.parent_ids?.join(', ') || '—'}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteModal({ show: true, topic })
|
||||
}}
|
||||
class={styles['delete-button']}
|
||||
title="Удалить топик"
|
||||
aria-label="Удалить топик"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
if (topic.children && topic.children.length > 0) {
|
||||
result.push(...renderTopics(topic.children))
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет топик
|
||||
*/
|
||||
const updateTopic = async (updatedTopic: Topic) => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: UPDATE_TOPIC_MUTATION,
|
||||
variables: { topic_input: updatedTopic }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.update_topic.success) {
|
||||
props.onSuccess('Топик успешно обновлен')
|
||||
setEditModal({ show: false, topic: null })
|
||||
await loadTopics() // Перезагружаем список
|
||||
} else {
|
||||
throw new Error(result.data.update_topic.message || 'Ошибка обновления топика')
|
||||
}
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка обновления топика: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет топик
|
||||
*/
|
||||
const deleteTopic = async (topicId: number) => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: DELETE_TOPIC_MUTATION,
|
||||
variables: { id: topicId }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.delete_topic_by_id.success) {
|
||||
props.onSuccess('Топик успешно удален')
|
||||
setDeleteModal({ show: false, topic: null })
|
||||
await loadTopics() // Перезагружаем список
|
||||
} else {
|
||||
throw new Error(result.data.delete_topic_by_id.message || 'Ошибка удаления топика')
|
||||
}
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка удаления топика: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление топиками</h2>
|
||||
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
||||
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
|
||||
<select
|
||||
value={sortBy()}
|
||||
onInput={(e) => setSortBy(e.target.value as 'id' | 'title')}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px',
|
||||
'font-size': '14px'
|
||||
}}
|
||||
>
|
||||
<option value="id">По ID</option>
|
||||
<option value="title">По названию</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortDirection()}
|
||||
onInput={(e) => setSortDirection(e.target.value as 'asc' | 'desc')}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px',
|
||||
'font-size': '14px'
|
||||
}}
|
||||
>
|
||||
<option value="asc">↑ По возрастанию</option>
|
||||
<option value="desc">↓ По убыванию</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={loadTopics} disabled={loading()}>
|
||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<div class="loading-screen">
|
||||
<div class="loading-spinner" />
|
||||
<div>Загрузка топиков...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table class={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Slug</th>
|
||||
<th>Описание</th>
|
||||
<th>Сообщество</th>
|
||||
<th>Родители</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={renderTopics(topics())}>{(row) => row}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<TopicEditModal
|
||||
isOpen={editModal().show}
|
||||
topic={editModal().topic}
|
||||
onClose={() => setEditModal({ show: false, topic: null })}
|
||||
onSave={updateTopic}
|
||||
/>
|
||||
|
||||
{/* Модальное окно подтверждения удаления */}
|
||||
<Modal
|
||||
isOpen={deleteModal().show}
|
||||
onClose={() => setDeleteModal({ show: false, topic: null })}
|
||||
title="Подтверждение удаления"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
|
||||
</p>
|
||||
<p class={styles['warning-text']}>
|
||||
Это действие нельзя отменить. Все дочерние топики также будут удалены.
|
||||
</p>
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteModal().topic && deleteTopic(deleteModal().topic!.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicsRoute
|
Reference in New Issue
Block a user