0.5.10-invites-crud
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
2025-06-30 22:19:46 +03:00
parent 1e2c85e56a
commit 41395eb7c6
14 changed files with 1748 additions and 385 deletions

View File

@@ -12,6 +12,7 @@ import AuthorsRoute from './routes/authors'
import CollectionsRoute from './routes/collections'
import CommunitiesRoute from './routes/communities'
import EnvRoute from './routes/env'
import InvitesRoute from './routes/invites'
import ShoutsRoute from './routes/shouts'
import TopicsRoute from './routes/topics'
import styles from './styles/Admin.module.css'
@@ -140,6 +141,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
>
Коллекции
</Button>
<Button
variant={activeTab() === 'invites' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/invites')}
>
Приглашения
</Button>
<Button
variant={activeTab() === 'env' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/env')}
@@ -179,6 +186,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'invites'}>
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'env'}>
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
</Show>

View File

@@ -30,6 +30,14 @@ export const ADMIN_UPDATE_ENV_VARIABLE_MUTATION = `
}
`
export const CREATE_TOPIC_MUTATION = `
mutation CreateTopic($topic_input: TopicInput!) {
create_topic(topic_input: $topic_input) {
error
}
}
`
export const UPDATE_TOPIC_MUTATION = `
mutation UpdateTopic($topic_input: TopicInput!) {
update_topic(topic_input: $topic_input) {
@@ -46,6 +54,14 @@ export const DELETE_TOPIC_MUTATION = `
}
`
export const CREATE_COMMUNITY_MUTATION = `
mutation CreateCommunity($community_input: CommunityInput!) {
create_community(community_input: $community_input) {
error
}
}
`
export const UPDATE_COMMUNITY_MUTATION = `
mutation UpdateCommunity($community_input: CommunityInput!) {
update_community(community_input: $community_input) {
@@ -85,3 +101,30 @@ export const DELETE_COLLECTION_MUTATION = `
}
}
`
export const ADMIN_CREATE_INVITE_MUTATION = `
mutation AdminCreateInvite($invite: AdminInviteUpdateInput!) {
adminCreateInvite(invite: $invite) {
success
error
}
}
`
export const ADMIN_UPDATE_INVITE_MUTATION = `
mutation AdminUpdateInvite($invite: AdminInviteUpdateInput!) {
adminUpdateInvite(invite: $invite) {
success
error
}
}
`
export const ADMIN_DELETE_INVITE_MUTATION = `
mutation AdminDeleteInvite($inviter_id: Int!, $author_id: Int!, $shout_id: Int!) {
adminDeleteInvite(inviter_id: $inviter_id, author_id: $author_id, shout_id: $shout_id) {
success
error
}
}
`

View File

@@ -17,15 +17,10 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
cover
cover_caption
media {
type
url
title
body
source
pic
date
genre
artist
lyrics
caption
}
seo
created_at
@@ -35,23 +30,45 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
deleted_at
created_by {
id
email
name
email
slug
}
updated_by {
id
name
email
slug
}
deleted_by {
id
name
email
slug
}
community {
id
name
slug
}
authors {
id
name
email
slug
}
topics {
id
title
slug
}
version_of
draft
stat {
rating
comments_count
viewed
last_commented_at
}
}
total
@@ -115,21 +132,24 @@ export const GET_COMMUNITIES_QUERY: string =
gql`
query GetCommunities {
get_communities_all {
id
slug
name
desc
pic
created_at
created_by {
communities {
id
slug
name
email
}
stat {
shouts
followers
authors
desc
pic
created_at
created_by {
id
name
email
slug
}
stat {
shouts
followers
authors
}
}
}
}
@@ -139,17 +159,22 @@ export const GET_TOPICS_QUERY: string =
gql`
query GetTopics {
get_topics_all {
id
slug
title
body
pic
community
parent_ids
stat {
shouts
authors
followers
topics {
id
slug
title
body
pic
community
parent_ids
stat {
shouts
followers
authors
comments
}
oid
is_main
}
}
}
@@ -159,19 +184,64 @@ export const GET_COLLECTIONS_QUERY: string =
gql`
query GetCollections {
get_collections_all {
id
slug
title
desc
pic
amount
created_at
published_at
created_by {
collections {
id
name
email
slug
title
desc
pic
amount
published_at
created_at
created_by {
id
name
email
slug
}
}
}
}
`.loc?.source.body || ''
export const ADMIN_GET_INVITES_QUERY: string =
gql`
query AdminGetInvites($limit: Int, $offset: Int, $search: String, $status: String) {
adminGetInvites(limit: $limit, offset: $offset, search: $search, status: $status) {
invites {
inviter_id
author_id
shout_id
status
inviter {
id
name
email
slug
}
author {
id
name
email
slug
}
shout {
id
title
slug
created_by {
id
name
email
slug
}
}
created_at
}
total
page
perPage
totalPages
}
}
`.loc?.source.body || ''

View File

@@ -0,0 +1,187 @@
import { Component, createEffect, createSignal } from 'solid-js'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
interface Collection {
id: number
slug: string
title: string
desc?: string
pic?: string
amount?: number
published_at?: number
created_at: number
created_by: {
id: number
name: string
email: string
}
}
interface CollectionEditModalProps {
isOpen: boolean
collection: Collection | null // null для создания новой
onClose: () => void
onSave: (collection: Partial<Collection>) => void
}
/**
* Модальное окно для создания и редактирования коллекций
*/
const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
slug: '',
title: '',
desc: '',
pic: ''
})
const [errors, setErrors] = createSignal<Record<string, string>>({})
// Синхронизация с props.collection
createEffect(() => {
if (props.isOpen) {
if (props.collection) {
// Редактирование существующей коллекции
setFormData({
slug: props.collection.slug,
title: props.collection.title,
desc: props.collection.desc || '',
pic: props.collection.pic || ''
})
} else {
// Создание новой коллекции
setFormData({
slug: '',
title: '',
desc: '',
pic: ''
})
}
setErrors({})
}
})
const validateForm = () => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация slug
if (!data.slug.trim()) {
newErrors.slug = 'Slug обязателен'
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
}
// Валидация названия
if (!data.title.trim()) {
newErrors.title = 'Название обязательно'
}
// Валидация URL картинки (если указан)
if (data.pic.trim() && !/^https?:\/\/.+/.test(data.pic)) {
newErrors.pic = 'Некорректный URL картинки'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleSave = () => {
if (!validateForm()) {
return
}
const collectionData = { ...formData() }
props.onSave(collectionData)
}
const isCreating = () => props.collection === null
const modalTitle = () =>
isCreating() ? 'Создание новой коллекции' : `Редактирование коллекции: ${props.collection?.title || ''}`
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
<div class={styles['modal-content']}>
<div class={formStyles.form}>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Slug <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().slug}
onInput={(e) => updateField('slug', e.target.value.toLowerCase())}
class={`${formStyles.input} ${errors().slug ? formStyles.inputError : ''}`}
placeholder="уникальный-идентификатор"
required
/>
<div class={formStyles.fieldHint}>
Используется в URL коллекции. Только латинские буквы, цифры, дефисы и подчеркивания.
</div>
{errors().slug && <div class={formStyles.fieldError}>{errors().slug}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Название <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().title}
onInput={(e) => updateField('title', e.target.value)}
class={`${formStyles.input} ${errors().title ? formStyles.inputError : ''}`}
placeholder="Название коллекции"
required
/>
{errors().title && <div class={formStyles.fieldError}>{errors().title}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Описание</label>
<textarea
value={formData().desc}
onInput={(e) => updateField('desc', e.target.value)}
class={formStyles.input}
style={{
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание коллекции..."
/>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Картинка (URL)</label>
<input
type="text"
value={formData().pic}
onInput={(e) => updateField('pic', e.target.value)}
class={`${formStyles.input} ${errors().pic ? formStyles.inputError : ''}`}
placeholder="https://example.com/image.jpg"
/>
{errors().pic && <div class={formStyles.fieldError}>{errors().pic}</div>}
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave}>
{isCreating() ? 'Создать' : 'Сохранить'}
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default CollectionEditModal

View File

@@ -0,0 +1,192 @@
import { Component, createEffect, createSignal } from 'solid-js'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
interface 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 CommunityEditModalProps {
isOpen: boolean
community: Community | null // null для создания нового
onClose: () => void
onSave: (community: Partial<Community>) => void
}
/**
* Модальное окно для создания и редактирования сообществ
*/
const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
slug: '',
name: '',
desc: '',
pic: ''
})
const [errors, setErrors] = createSignal<Record<string, string>>({})
// Синхронизация с props.community
createEffect(() => {
if (props.isOpen) {
if (props.community) {
// Редактирование существующего сообщества
setFormData({
slug: props.community.slug,
name: props.community.name,
desc: props.community.desc || '',
pic: props.community.pic
})
} else {
// Создание нового сообщества
setFormData({
slug: '',
name: '',
desc: '',
pic: ''
})
}
setErrors({})
}
})
const validateForm = () => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация slug
if (!data.slug.trim()) {
newErrors.slug = 'Slug обязателен'
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
}
// Валидация названия
if (!data.name.trim()) {
newErrors.name = 'Название обязательно'
}
// Валидация URL картинки (если указан)
if (data.pic.trim() && !/^https?:\/\/.+/.test(data.pic)) {
newErrors.pic = 'Некорректный URL картинки'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const updateField = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleSave = () => {
if (!validateForm()) {
return
}
const communityData = { ...formData() }
props.onSave(communityData)
}
const isCreating = () => props.community === null
const modalTitle = () =>
isCreating()
? 'Создание нового сообщества'
: `Редактирование сообщества: ${props.community?.name || ''}`
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
<div class={styles['modal-content']}>
<div class={formStyles.form}>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Slug <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().slug}
onInput={(e) => updateField('slug', e.target.value.toLowerCase())}
class={`${formStyles.input} ${errors().slug ? formStyles.inputError : ''}`}
placeholder="уникальный-идентификатор"
required
/>
<div class={formStyles.fieldHint}>
Используется в URL сообщества. Только латинские буквы, цифры, дефисы и подчеркивания.
</div>
{errors().slug && <div class={formStyles.fieldError}>{errors().slug}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Название <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().name}
onInput={(e) => updateField('name', e.target.value)}
class={`${formStyles.input} ${errors().name ? formStyles.inputError : ''}`}
placeholder="Название сообщества"
required
/>
{errors().name && <div class={formStyles.fieldError}>{errors().name}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Описание</label>
<textarea
value={formData().desc}
onInput={(e) => updateField('desc', e.target.value)}
class={formStyles.input}
style={{
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание сообщества..."
/>
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Картинка (URL)</label>
<input
type="text"
value={formData().pic}
onInput={(e) => updateField('pic', e.target.value)}
class={`${formStyles.input} ${errors().pic ? formStyles.inputError : ''}`}
placeholder="https://example.com/image.jpg"
/>
{errors().pic && <div class={formStyles.fieldError}>{errors().pic}</div>}
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave}>
{isCreating() ? 'Создать' : 'Сохранить'}
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default CommunityEditModal

View File

@@ -0,0 +1,234 @@
import { Component, createEffect, createSignal } from 'solid-js'
import formStyles from '../styles/Form.module.css'
import styles from '../styles/Modal.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
interface Author {
id: number
name: string
email: string
slug: string
}
interface Shout {
id: number
title: string
slug: string
created_by: Author
}
interface Invite {
inviter_id: number
author_id: number
shout_id: number
status: 'PENDING' | 'ACCEPTED' | 'REJECTED'
inviter: Author
author: Author
shout: Shout
created_at?: number
}
interface InviteEditModalProps {
isOpen: boolean
invite: Invite | null // null для создания нового
onClose: () => void
onSave: (invite: Partial<Invite>) => void
}
/**
* Модальное окно для создания и редактирования приглашений
*/
const InviteEditModal: Component<InviteEditModalProps> = (props) => {
const [formData, setFormData] = createSignal({
inviter_id: 0,
author_id: 0,
shout_id: 0,
status: 'PENDING' as 'PENDING' | 'ACCEPTED' | 'REJECTED'
})
const [errors, setErrors] = createSignal<Record<string, string>>({})
// Синхронизация с props.invite
createEffect(() => {
if (props.isOpen) {
if (props.invite) {
// Редактирование существующего приглашения
setFormData({
inviter_id: props.invite.inviter_id,
author_id: props.invite.author_id,
shout_id: props.invite.shout_id,
status: props.invite.status
})
} else {
// Создание нового приглашения
setFormData({
inviter_id: 0,
author_id: 0,
shout_id: 0,
status: 'PENDING'
})
}
setErrors({})
}
})
const validateForm = () => {
const newErrors: Record<string, string> = {}
const data = formData()
// Валидация ID приглашающего
if (!data.inviter_id || data.inviter_id <= 0) {
newErrors.inviter_id = 'ID приглашающего обязателен'
}
// Валидация ID приглашаемого
if (!data.author_id || data.author_id <= 0) {
newErrors.author_id = 'ID приглашаемого обязателен'
}
// Валидация ID публикации
if (!data.shout_id || data.shout_id <= 0) {
newErrors.shout_id = 'ID публикации обязателен'
}
// Проверка что приглашающий и приглашаемый не совпадают
if (data.inviter_id === data.author_id && data.inviter_id > 0) {
newErrors.author_id = 'Приглашающий и приглашаемый не могут быть одним и тем же автором'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const updateField = (field: string, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Очищаем ошибку для поля при изменении
setErrors((prev) => ({ ...prev, [field]: '' }))
}
const handleSave = () => {
if (!validateForm()) {
return
}
const inviteData = { ...formData() }
props.onSave(inviteData)
}
const isCreating = () => props.invite === null
const modalTitle = () =>
isCreating()
? 'Создание нового приглашения'
: `Редактирование приглашения: ${props.invite?.inviter.name || ''}${props.invite?.author.name || ''}`
return (
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
<div class={styles['modal-content']}>
<div class={formStyles.form}>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
ID приглашающего <span style={{ color: 'red' }}>*</span>
</label>
<input
type="number"
value={formData().inviter_id}
onInput={(e) => updateField('inviter_id', parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().inviter_id ? formStyles.inputError : ''}`}
placeholder="1"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<div class={formStyles.fieldHint}>
ID автора, который отправляет приглашение
</div>
{errors().inviter_id && <div class={formStyles.fieldError}>{errors().inviter_id}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
ID приглашаемого <span style={{ color: 'red' }}>*</span>
</label>
<input
type="number"
value={formData().author_id}
onInput={(e) => updateField('author_id', parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().author_id ? formStyles.inputError : ''}`}
placeholder="2"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<div class={formStyles.fieldHint}>
ID автора, которого приглашают к сотрудничеству
</div>
{errors().author_id && <div class={formStyles.fieldError}>{errors().author_id}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
ID публикации <span style={{ color: 'red' }}>*</span>
</label>
<input
type="number"
value={formData().shout_id}
onInput={(e) => updateField('shout_id', parseInt(e.target.value) || 0)}
class={`${formStyles.input} ${errors().shout_id ? formStyles.inputError : ''}`}
placeholder="123"
required
disabled={!isCreating()} // При редактировании ID нельзя менять
/>
<div class={formStyles.fieldHint}>
ID публикации, к которой приглашают на сотрудничество
</div>
{errors().shout_id && <div class={formStyles.fieldError}>{errors().shout_id}</div>}
</div>
<div class={formStyles['form-group']}>
<label class={formStyles.label}>
Статус <span style={{ color: 'red' }}>*</span>
</label>
<select
value={formData().status}
onChange={(e) => updateField('status', e.target.value)}
class={formStyles.input}
required
>
<option value="PENDING">Ожидает ответа</option>
<option value="ACCEPTED">Принято</option>
<option value="REJECTED">Отклонено</option>
</select>
<div class={formStyles.fieldHint}>
Текущий статус приглашения
</div>
</div>
{/* Информация о связанных объектах при редактировании */}
{!isCreating() && props.invite && (
<div class={formStyles['form-group']}>
<label class={formStyles.label}>Информация о приглашении</label>
<div class={formStyles.fieldHint} style={{ 'margin-bottom': '8px' }}>
<strong>Приглашающий:</strong> {props.invite.inviter.name} ({props.invite.inviter.email})
</div>
<div class={formStyles.fieldHint} style={{ 'margin-bottom': '8px' }}>
<strong>Приглашаемый:</strong> {props.invite.author.name} ({props.invite.author.email})
</div>
<div class={formStyles.fieldHint}>
<strong>Публикация:</strong> {props.invite.shout.title}
</div>
</div>
)}
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button variant="primary" onClick={handleSave}>
{isCreating() ? 'Создать' : 'Сохранить'}
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default InviteEditModal

View File

@@ -5,6 +5,7 @@ import {
UPDATE_COLLECTION_MUTATION
} from '../graphql/mutations'
import { GET_COLLECTIONS_QUERY } from '../graphql/queries'
import CollectionEditModal from '../modals/CollectionEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
@@ -49,14 +50,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
})
const [createModal, setCreateModal] = createSignal(false)
// Форма для редактирования/создания
const [formData, setFormData] = createSignal({
slug: '',
title: '',
desc: '',
pic: ''
})
/**
* Загружает список всех коллекций
*/
@@ -98,12 +91,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
* Открывает модалку редактирования
*/
const openEditModal = (collection: Collection) => {
setFormData({
slug: collection.slug,
title: collection.title,
desc: collection.desc || '',
pic: collection.pic
})
setEditModal({ show: true, collection })
}
@@ -111,28 +98,25 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
* Открывает модалку создания
*/
const openCreateModal = () => {
setFormData({
slug: '',
title: '',
desc: '',
pic: ''
})
setCreateModal(true)
}
/**
* Создает новую коллекцию
* Обрабатывает сохранение коллекции (создание или обновление)
*/
const createCollection = async () => {
const handleSaveCollection = async (collectionData: Partial<Collection>) => {
try {
const isCreating = createModal()
const mutation = isCreating ? CREATE_COLLECTION_MUTATION : UPDATE_COLLECTION_MUTATION
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: CREATE_COLLECTION_MUTATION,
variables: { collection_input: formData() }
query: mutation,
variables: { collection_input: collectionData }
})
})
@@ -142,49 +126,19 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
throw new Error(result.errors[0].message)
}
if (result.data.create_collection.error) {
throw new Error(result.data.create_collection.error)
const resultData = isCreating ? result.data.create_collection : result.data.update_collection
if (resultData.error) {
throw new Error(resultData.error)
}
props.onSuccess('Коллекция успешно создана')
props.onSuccess(isCreating ? 'Коллекция успешно создана' : 'Коллекция успешно обновлена')
setCreateModal(false)
await loadCollections()
} catch (error) {
props.onError(`Ошибка создания коллекции: ${(error as Error).message}`)
}
}
/**
* Обновляет коллекцию
*/
const updateCollection = async () => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: UPDATE_COLLECTION_MUTATION,
variables: { collection_input: formData() }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.update_collection.error) {
throw new Error(result.data.update_collection.error)
}
props.onSuccess('Коллекция успешно обновлена')
setEditModal({ show: false, collection: null })
await loadCollections()
} catch (error) {
props.onError(`Ошибка обновления коллекции: ${(error as Error).message}`)
props.onError(
`Ошибка ${createModal() ? 'создания' : 'обновления'} коллекции: ${(error as Error).message}`
)
}
}
@@ -230,7 +184,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
return (
<div class={styles.container}>
<div class={styles.header}>
<h2>Управление коллекциями</h2>
<div style={{ display: 'flex', gap: '10px' }}>
<Button onClick={openCreateModal} variant="primary">
Создать коллекцию
@@ -313,181 +266,20 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
</Show>
{/* Модальное окно создания */}
<Modal isOpen={createModal()} onClose={() => setCreateModal(false)} title="Создание новой коллекции">
<div style={{ padding: '20px' }}>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Slug <span style={{ color: 'red' }}>*</span>
</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
placeholder="my-collection"
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Название <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().title}
onInput={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
required
placeholder="Моя коллекция"
/>
</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={() => setCreateModal(false)}>
Отмена
</Button>
<Button variant="primary" onClick={createCollection}>
Создать
</Button>
</div>
</div>
</Modal>
<CollectionEditModal
isOpen={createModal()}
collection={null}
onClose={() => setCreateModal(false)}
onSave={handleSaveCollection}
/>
{/* Модальное окно редактирования */}
<Modal
<CollectionEditModal
isOpen={editModal().show}
collection={editModal().collection}
onClose={() => setEditModal({ show: false, collection: null })}
title={`Редактирование коллекции: ${editModal().collection?.title || ''}`}
>
<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().title}
onInput={(e) => setFormData((prev) => ({ ...prev, title: 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, collection: null })}>
Отмена
</Button>
<Button variant="primary" onClick={updateCollection}>
Сохранить
</Button>
</div>
</div>
</Modal>
onSave={handleSaveCollection}
/>
{/* Модальное окно подтверждения удаления */}
<Modal

View File

@@ -1,6 +1,11 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { DELETE_COMMUNITY_MUTATION, UPDATE_COMMUNITY_MUTATION } from '../graphql/mutations'
import {
CREATE_COMMUNITY_MUTATION,
DELETE_COMMUNITY_MUTATION,
UPDATE_COMMUNITY_MUTATION
} from '../graphql/mutations'
import { GET_COMMUNITIES_QUERY } from '../graphql/queries'
import CommunityEditModal from '../modals/CommunityEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
@@ -46,13 +51,8 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
show: false,
community: null
})
// Форма для редактирования
const [formData, setFormData] = createSignal({
slug: '',
name: '',
desc: '',
pic: ''
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
show: false
})
/**
@@ -92,32 +92,36 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
}
/**
* Открывает модалку создания
*/
const openCreateModal = () => {
setCreateModal({ show: true })
}
/**
* Открывает модалку редактирования
*/
const openEditModal = (community: Community) => {
setFormData({
slug: community.slug,
name: community.name,
desc: community.desc || '',
pic: community.pic
})
setEditModal({ show: true, community })
}
/**
* Обновляет сообщество
* Обрабатывает сохранение сообщества (создание или обновление)
*/
const updateCommunity = async () => {
const handleSaveCommunity = async (communityData: Partial<Community>) => {
try {
const isCreating = !editModal().community && createModal().show
const mutation = isCreating ? CREATE_COMMUNITY_MUTATION : UPDATE_COMMUNITY_MUTATION
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: UPDATE_COMMUNITY_MUTATION,
variables: { community_input: formData() }
query: mutation,
variables: { community_input: communityData }
})
})
@@ -127,15 +131,19 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
throw new Error(result.errors[0].message)
}
if (result.data.update_community.error) {
throw new Error(result.data.update_community.error)
const resultData = isCreating ? result.data.create_community : result.data.update_community
if (resultData.error) {
throw new Error(resultData.error)
}
props.onSuccess('Сообщество успешно обновлено')
props.onSuccess(isCreating ? 'Сообщество успешно создано' : 'Сообщество успешно обновлено')
setCreateModal({ show: false })
setEditModal({ show: false, community: null })
await loadCommunities()
} catch (error) {
props.onError(`Ошибка обновления сообщества: ${(error as Error).message}`)
props.onError(
`Ошибка ${createModal().show ? 'создания' : 'обновления'} сообщества: ${(error as Error).message}`
)
}
}
@@ -181,10 +189,12 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
return (
<div class={styles.container}>
<div class={styles.header}>
<h2>Управление сообществами</h2>
<Button onClick={loadCommunities} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
<Button variant="primary" onClick={openCreateModal}>
Создать сообщество
</Button>
</div>
<Show
@@ -260,93 +270,21 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
</table>
</Show>
{/* Модальное окно создания */}
<CommunityEditModal
isOpen={createModal().show}
community={null}
onClose={() => setCreateModal({ show: false })}
onSave={handleSaveCommunity}
/>
{/* Модальное окно редактирования */}
<Modal
<CommunityEditModal
isOpen={editModal().show}
community={editModal().community}
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>
onSave={handleSaveCommunity}
/>
{/* Модальное окно подтверждения удаления */}
<Modal

424
panel/routes/invites.tsx Normal file
View File

@@ -0,0 +1,424 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import {
ADMIN_CREATE_INVITE_MUTATION,
ADMIN_DELETE_INVITE_MUTATION,
ADMIN_UPDATE_INVITE_MUTATION
} from '../graphql/mutations'
import { ADMIN_GET_INVITES_QUERY } from '../graphql/queries'
import InviteEditModal from '../modals/InviteEditModal'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
/**
* Интерфейсы для приглашений
*/
interface Author {
id: number
name: string
email: string
slug: string
}
interface Shout {
id: number
title: string
slug: string
created_by: Author
}
interface Invite {
inviter_id: number
author_id: number
shout_id: number
status: 'PENDING' | 'ACCEPTED' | 'REJECTED'
inviter: Author
author: Author
shout: Shout
created_at?: number
}
interface InvitesRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
/**
* Компонент для управления приглашениями
*/
const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const [invites, setInvites] = createSignal<Invite[]>([])
const [loading, setLoading] = createSignal(false)
const [search, setSearch] = createSignal('')
const [statusFilter, setStatusFilter] = createSignal('all')
const [pagination, setPagination] = createSignal({
page: 1,
perPage: 10,
total: 0,
totalPages: 1
})
const [editModal, setEditModal] = createSignal<{ show: boolean; invite: Invite | null }>({
show: false,
invite: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; invite: Invite | null }>({
show: false,
invite: null
})
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
show: false
})
/**
* Загружает список приглашений с учетом фильтров и пагинации
*/
const loadInvites = async (page: number = 1) => {
setLoading(true)
try {
const limit = pagination().perPage
const offset = (page - 1) * limit
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: ADMIN_GET_INVITES_QUERY,
variables: {
limit,
offset,
search: search().trim() || null,
status: statusFilter() === 'all' ? null : statusFilter()
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const data = result.data.adminGetInvites
setInvites(data.invites || [])
setPagination({
page: data.page || 1,
perPage: data.perPage || 10,
total: data.total || 0,
totalPages: data.totalPages || 1
})
} catch (error) {
props.onError(`Ошибка загрузки приглашений: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
/**
* Обработчик изменения страницы
*/
const handlePageChange = (page: number) => {
void loadInvites(page)
}
/**
* Обработчик поиска
*/
const handleSearch = () => {
void loadInvites(1) // Сброс на первую страницу при поиске
}
/**
* Обработчик изменения фильтра статуса
*/
const handleStatusFilterChange = (status: string) => {
setStatusFilter(status)
void loadInvites(1)
}
/**
* Получает отображаемое название статуса
*/
const getStatusDisplay = (status: string) => {
switch (status) {
case 'PENDING':
return { text: 'Ожидает', badge: 'warning' }
case 'ACCEPTED':
return { text: 'Принято', badge: 'success' }
case 'REJECTED':
return { text: 'Отклонено', badge: 'error' }
default:
return { text: status, badge: 'secondary' }
}
}
/**
* Открывает модалку создания
*/
const openCreateModal = () => {
setCreateModal({ show: true })
}
/**
* Открывает модалку редактирования
*/
const openEditModal = (invite: Invite) => {
setEditModal({ show: true, invite })
}
/**
* Обрабатывает сохранение приглашения (создание или обновление)
*/
const handleSaveInvite = async (inviteData: Partial<Invite>) => {
try {
const isCreating = !editModal().invite && createModal().show
const mutation = isCreating ? ADMIN_CREATE_INVITE_MUTATION : ADMIN_UPDATE_INVITE_MUTATION
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: mutation,
variables: { invite: inviteData }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const resultData = isCreating ? result.data.adminCreateInvite : result.data.adminUpdateInvite
if (!resultData.success) {
throw new Error(resultData.error || 'Неизвестная ошибка')
}
props.onSuccess(isCreating ? 'Приглашение успешно создано' : 'Приглашение успешно обновлено')
setCreateModal({ show: false })
setEditModal({ show: false, invite: null })
await loadInvites(pagination().page)
} catch (error) {
props.onError(
`Ошибка ${createModal().show ? 'создания' : 'обновления'} приглашения: ${(error as Error).message}`
)
}
}
/**
* Удаляет приглашение
*/
const deleteInvite = async (invite: Invite) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: ADMIN_DELETE_INVITE_MUTATION,
variables: {
inviter_id: invite.inviter_id,
author_id: invite.author_id,
shout_id: invite.shout_id
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (!result.data.adminDeleteInvite.success) {
throw new Error(result.data.adminDeleteInvite.error || 'Неизвестная ошибка')
}
props.onSuccess('Приглашение успешно удалено')
setDeleteModal({ show: false, invite: null })
await loadInvites(pagination().page)
} catch (error) {
props.onError(`Ошибка удаления приглашения: ${(error as Error).message}`)
}
}
// Загружаем приглашения при монтировании компонента
onMount(() => {
void loadInvites()
})
return (
<div class={styles.container}>
<div class={styles.header}>
<div class={styles.controls}>
<input
type="text"
placeholder="Поиск приглашений..."
value={search()}
onInput={(e) => setSearch(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
class={styles.searchInput}
/>
<Button onClick={handleSearch} disabled={loading()}>
🔍
</Button>
<select
value={statusFilter()}
onChange={(e) => handleStatusFilterChange(e.target.value)}
class={styles.statusFilter}
>
<option value="all">Все статусы</option>
<option value="pending">Ожидает ответа</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
</div>
<Button variant="primary" onClick={openCreateModal}>
Создать приглашение
</Button>
</div>
<Show when={loading()}>
<div class={styles.loading}>Загрузка приглашений...</div>
</Show>
<Show when={!loading() && invites().length === 0}>
<div class={styles.empty}>Приглашения не найдены</div>
</Show>
<Show when={!loading() && invites().length > 0}>
<div class={styles.tableContainer}>
<table class={styles.table}>
<thead>
<tr>
<th>Приглашающий</th>
<th>Приглашаемый</th>
<th>Публикация</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={invites()}>
{(invite) => {
const statusDisplay = getStatusDisplay(invite.status)
return (
<tr
class={styles.clickableRow}
onClick={() => openEditModal(invite)}
title="Нажмите для редактирования"
>
<td>
<div>
<strong>{invite.inviter.name || 'Без имени'}</strong>
</div>
<div class={styles.subtitle}>{invite.inviter.email}</div>
<div class={styles.subtitle}>ID: {invite.inviter_id}</div>
</td>
<td>
<div>
<strong>{invite.author.name || 'Без имени'}</strong>
</div>
<div class={styles.subtitle}>{invite.author.email}</div>
<div class={styles.subtitle}>ID: {invite.author_id}</div>
</td>
<td>
<div>
<strong>{invite.shout.title}</strong>
</div>
<div class={styles.subtitle}>Автор: {invite.shout.created_by.name}</div>
<div class={styles.subtitle}>ID: {invite.shout_id}</div>
</td>
<td>
<span class={`${styles.badge} ${styles[statusDisplay.badge]}`}>
{statusDisplay.text}
</span>
</td>
<td>
<button
class={styles.deleteButton}
onClick={(e) => {
e.stopPropagation()
setDeleteModal({ show: true, invite })
}}
title="Удалить приглашение"
>
×
</button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
<Pagination
currentPage={pagination().page}
totalPages={pagination().totalPages}
total={pagination().total}
limit={pagination().perPage}
onPageChange={handlePageChange}
/>
</Show>
{/* Модальные окна */}
<InviteEditModal
isOpen={createModal().show}
invite={null}
onClose={() => setCreateModal({ show: false })}
onSave={handleSaveInvite}
/>
<InviteEditModal
isOpen={editModal().show}
invite={editModal().invite}
onClose={() => setEditModal({ show: false, invite: null })}
onSave={handleSaveInvite}
/>
{/* Модальное окно подтверждения удаления */}
<Modal
isOpen={deleteModal().show}
onClose={() => setDeleteModal({ show: false, invite: null })}
title="Подтверждение удаления"
size="small"
>
<div class={styles.deleteConfirmation}>
<p>
Вы действительно хотите удалить приглашение от{' '}
<strong>{deleteModal().invite?.inviter.name}</strong> для{' '}
<strong>{deleteModal().invite?.author.name}</strong> к публикации{' '}
<strong>"{deleteModal().invite?.shout.title}"</strong>?
</p>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, invite: null })}>
Отмена
</Button>
<Button
variant="danger"
onClick={() => deleteModal().invite && deleteInvite(deleteModal().invite!)}
>
Удалить
</Button>
</div>
</div>
</Modal>
</div>
)
}
export default InvitesRoute

View File

@@ -6,7 +6,7 @@
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 { CREATE_TOPIC_MUTATION, 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'
@@ -53,6 +53,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
show: false,
topic: null
})
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
show: false
})
/**
* Загружает список всех топиков
@@ -268,6 +271,40 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
}
}
/**
* Создает новый топик
*/
const createTopic = async (newTopic: Topic) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: CREATE_TOPIC_MUTATION,
variables: { topic_input: newTopic }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.create_topic.error) {
throw new Error(result.data.create_topic.error)
}
props.onSuccess('Топик успешно создан')
setCreateModal({ show: false })
await loadTopics() // Перезагружаем список
} catch (error) {
props.onError(`Ошибка создания топика: ${(error as Error).message}`)
}
}
/**
* Удаляет топик
*/
@@ -305,7 +342,6 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
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>
@@ -339,6 +375,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
<Button onClick={loadTopics} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
Создать тему
</Button>
</div>
</div>
@@ -369,6 +408,14 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
</table>
</Show>
{/* Модальное окно создания */}
<TopicEditModal
isOpen={createModal().show}
topic={null}
onClose={() => setCreateModal({ show: false })}
onSave={createTopic}
/>
{/* Модальное окно редактирования */}
<TopicEditModal
isOpen={editModal().show}