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

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

View File

@ -1,5 +1,47 @@
# Changelog
## [0.5.10] - 2025-06-30
### Новая функциональность CRUD приглашений
- **НОВОЕ**: Полноценное управление приглашениями в админ-панели:
- **Новая вкладка "Приглашения"**: Отдельная секция в админ-панели для управления приглашениями к сотрудничеству
- **Полная CRUD функциональность**: Создание, редактирование, удаление приглашений
- **Подробная таблица**: Приглашающий, приглашаемый, публикация, статус с детальной информацией
- **Клик для редактирования**: Нажатие на строку открывает модалку редактирования приглашения
- **Удаление с подтверждением**: Тонкая кнопка "×" для удаления с модальным окном подтверждения
- **Кнопка создания**: Возможность создания новых приглашений прямо из интерфейса
- **Фильтрация по статусу**: Все/Ожидает ответа/Принято/Отклонено
- **Поиск**: По email и именам приглашающего/приглашаемого, названию публикации, ID
- **Пагинация**: Полная поддержка пагинации для больших списков приглашений
- **Серверная часть**:
- **GraphQL схема**: Новые queries, mutations и input types для приглашений:
- `adminGetInvites` - получение списка приглашений с фильтрацией и пагинацией
- `adminCreateInvite` - создание нового приглашения
- `adminUpdateInvite` - обновление статуса приглашения
- `adminDeleteInvite` - удаление приглашения
- **Резолверы**: Полный набор администраторских резолверов с проверкой прав доступа
- **Авторизация**: Требуется роль admin для создания/редактирования/удаления приглашений
- **Валидация данных**: Проверка существования всех связанных объектов (авторы, публикации)
- **Предотвращение дублирования**: Проверка уникальности приглашений по составному ключу
- **Подробное логирование**: Отслеживание всех операций с приглашениями для аудита
- **Архитектурные улучшения**:
- **Модальное окно InviteEditModal**: Отдельный компонент для создания/редактирования приглашений
- **Автоматическое определение режима**: Модальное окно само определяет режим создания/редактирования
- **Валидация форм**: Проверка корректности ID, предотвращение самоприглашений
- **Составной первичный ключ**: Работа с уникальным идентификатором из трех полей (inviter_id, author_id, shout_id)
- **Статусные бейджи**: Цветовая индикация статусов (ожидает/принято/отклонено)
- **Информационные панели**: Отображение полной информации о связанных авторах и публикациях
- **ТЕХНИЧЕСКАЯ АРХИТЕКТУРА**:
- **Следование паттернам проекта**: Использование существующих компонентов Button, Modal, Pagination
- **Переиспользование стилей**: CSS модули Table.module.css, Form.module.css, Modal.module.css
- **Консистентный API**: Единый стиль GraphQL операций admin* с другими админскими функциями
- **TypeScript типизация**: Полная типизация всех интерфейсов приглашений и связанных объектов
- **Обработка ошибок**: Централизованная обработка ошибок с детальными сообщениями пользователю
## [0.5.9] - 2025-06-30
### Новая функциональность CRUD коллекций
@ -44,6 +86,49 @@
- **Оптимизированная конфигурация маршрутов**: Четкое разделение между API, статикой и SPA fallback
- **Совместимость с SolidJS Router**: Полная поддержка клиентского роутинга
### Исправления GraphQL схемы и расширение CRUD
- **ИСПРАВЛЕНО**: Поле `pic` в типе Collection:
- **Проблема**: GraphQL ошибка "Cannot query field 'pic' on type 'Collection'"
- **Решение**: Добавлено поле `pic: String` в тип Collection в `schema/type.graphql`
- **Результат**: Картинки коллекций корректно отображаются в админ-панели
- **НОВОЕ**: Полноценный CRUD для тем и сообществ:
- **Кнопки создания**: Добавлены кнопки "Создать тему" и "Создать сообщество" в соответствующие разделы админ-панели
- **Мутации создания**:
- `CREATE_TOPIC_MUTATION` для создания новых тем
- `CREATE_COMMUNITY_MUTATION` для создания новых сообществ
- **Модальные окна создания**: Полнофункциональные формы с валидацией для создания тем и сообществ
- **Интеграция с существующими резолверами**: Использование GraphQL мутаций `create_topic` и `create_community`
- **Результат**: Администраторы могут создавать новые темы и сообщества прямо из админ-панели
- **Архитектурные улучшения**:
- **Переиспользование компонентов**: TopicEditModal используется как для создания, так и для редактирования тем
- **Консистентный UX**: Единый стиль модальных окон создания/редактирования для всех сущностей
- **Валидация форм**: Обязательные поля (slug, name) с placeholder'ами и подсказками
- **Автоматическое обновление**: После создания/редактирования списки автоматически перезагружаются
### Рефакторинг модальных окон
- **РЕФАКТОРИНГ**: Изоляция модальных окон в отдельные компоненты:
- **Проблема**: Модальные окна создания/редактирования находились прямо в компонентах маршрутов, нарушая принцип разделения ответственности
- **Решение**: Создание отдельных компонентов в папке `@/modals`:
- `CommunityEditModal.tsx` - для создания и редактирования сообществ
- `CollectionEditModal.tsx` - для создания и редактирования коллекций
- **Архитектурные улучшения**:
- **Следование традициям проекта**: Все модальные окна теперь изолированы в отдельные компоненты (`EnvVariableModal`, `RolesModal`, `ShoutBodyModal`, `TopicEditModal`)
- **Переиспользование паттернов**: Единый стиль props, валидации и обработки ошибок
- **Лучшая типизация**: TypeScript интерфейсы для всех props компонентов
- **Упрощение роутов**: Убрана сложная логика форм из маршрутов - теперь только логика API вызовов
- **Валидация форм**: Централизованная валидация в модальных компонентах с real-time обратной связью
- **Результат**: Более чистая архитектура, лучшее разделение ответственности, упрощение тестирования
- **ТЕХНИЧЕСКАЯ АРХИТЕКТУРА**:
- **Унификация API**: Единый паттерн `onSave(data: Partial<Entity>)` для всех модальных окон создания/редактирования
- **Автоматическое определение режима**: Модальные окна сами определяют режим создания/редактирования по наличию entity в props
- **Очистка состояния**: Автоматический сброс ошибок и формы при открытии/закрытии модальных окон
- **Консистентные стили**: Переиспользование CSS модулей `Form.module.css` и `Modal.module.css`
## [0.5.8] - 2025-06-30
### Улучшения интерфейса публикаций

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}

View File

@ -9,6 +9,7 @@ from sqlalchemy.sql import func, select
from auth.decorators import admin_auth_required
from auth.orm import Author, AuthorRole, Role
from orm.invite import Invite, InviteStatus
from orm.shout import Shout
from services.db import local_session
from services.env import EnvManager, EnvVariable
@ -626,3 +627,307 @@ async def admin_restore_shout(_: None, info: GraphQLResolveInfo, shout_id: int)
error_msg = f"Ошибка при восстановлении публикации: {e!s}"
logger.error(error_msg)
return {"success": False, "error": error_msg}
# === CRUD для приглашений ===
@query.field("adminGetInvites")
@admin_auth_required
async def admin_get_invites(
_: None, _info: GraphQLResolveInfo, limit: int = 10, offset: int = 0, search: str = "", status: str = "all"
) -> dict[str, Any]:
"""
Получает список приглашений для админ-панели с поддержкой пагинации и поиска
Args:
_info: Контекст GraphQL запроса
limit: Максимальное количество записей для получения
offset: Смещение в списке результатов
search: Строка поиска (по email приглашающего/приглашаемого, названию публикации или ID)
status: Фильтр по статусу ("all", "pending", "accepted", "rejected")
Returns:
Пагинированный список приглашений
"""
try:
# Нормализуем параметры
limit = max(1, min(100, limit or 10))
offset = max(0, offset or 0)
with local_session() as session:
# Базовый запрос с загрузкой связанных объектов
query = session.query(Invite).options(
joinedload(Invite.inviter),
joinedload(Invite.author),
joinedload(Invite.shout).joinedload(Shout.created_by_author),
)
# Фильтр по статусу
if status and status != "all":
status_enum = InviteStatus[status.upper()]
query = query.filter(Invite.status == status_enum.value)
# Применяем фильтр поиска, если указан
if search and search.strip():
search_term = f"%{search.strip().lower()}%"
query = (
query.join(Invite.inviter.of_type(Author), aliased=True)
.join(Invite.author.of_type(Author), aliased=True)
.join(Invite.shout)
.filter(
or_(
# Поиск по email приглашающего
Invite.inviter.has(Author.email.ilike(search_term)),
# Поиск по имени приглашающего
Invite.inviter.has(Author.name.ilike(search_term)),
# Поиск по email приглашаемого
Invite.author.has(Author.email.ilike(search_term)),
# Поиск по имени приглашаемого
Invite.author.has(Author.name.ilike(search_term)),
# Поиск по названию публикации
Invite.shout.has(Shout.title.ilike(search_term)),
# Поиск по ID приглашающего
cast(Invite.inviter_id, String).ilike(search_term),
# Поиск по ID приглашаемого
cast(Invite.author_id, String).ilike(search_term),
# Поиск по ID публикации
cast(Invite.shout_id, String).ilike(search_term),
)
)
)
# Получаем общее количество записей
total_count = query.count()
# Вычисляем информацию о пагинации
per_page = limit
total_pages = ceil(total_count / per_page)
current_page = (offset // per_page) + 1 if per_page > 0 else 1
# Применяем пагинацию и сортировку (по ID приглашающего, затем автора, затем публикации)
invites = (
query.order_by(Invite.inviter_id, Invite.author_id, Invite.shout_id).offset(offset).limit(limit).all()
)
# Преобразуем в формат для API
return {
"invites": [
{
"inviter_id": invite.inviter_id,
"author_id": invite.author_id,
"shout_id": invite.shout_id,
"status": invite.status,
"inviter": {
"id": invite.inviter.id,
"name": invite.inviter.name or "Без имени",
"email": invite.inviter.email,
"slug": invite.inviter.slug,
},
"author": {
"id": invite.author.id,
"name": invite.author.name or "Без имени",
"email": invite.author.email,
"slug": invite.author.slug,
},
"shout": {
"id": invite.shout.id,
"title": invite.shout.title,
"slug": invite.shout.slug,
"created_by": {
"id": invite.shout.created_by_author.id,
"name": invite.shout.created_by_author.name or "Без имени",
"email": invite.shout.created_by_author.email,
"slug": invite.shout.created_by_author.slug,
},
},
"created_at": None, # У приглашений нет created_at поля в текущей модели
}
for invite in invites
],
"total": total_count,
"page": current_page,
"perPage": per_page,
"totalPages": total_pages,
}
except Exception as e:
import traceback
logger.error(f"Ошибка при получении списка приглашений: {e!s}")
logger.error(traceback.format_exc())
msg = f"Не удалось получить список приглашений: {e!s}"
raise GraphQLError(msg) from e
@mutation.field("adminCreateInvite")
@admin_auth_required
async def admin_create_invite(_: None, _info: GraphQLResolveInfo, invite: dict[str, Any]) -> dict[str, Any]:
"""
Создает новое приглашение
Args:
_info: Контекст GraphQL запроса
invite: Данные приглашения
Returns:
Результат операции
"""
try:
inviter_id = invite["inviter_id"]
author_id = invite["author_id"]
shout_id = invite["shout_id"]
status = invite["status"]
with local_session() as session:
# Проверяем существование всех связанных объектов
inviter = session.query(Author).filter(Author.id == inviter_id).first()
if not inviter:
return {"success": False, "error": f"Приглашающий автор с ID {inviter_id} не найден"}
author = session.query(Author).filter(Author.id == author_id).first()
if not author:
return {"success": False, "error": f"Приглашаемый автор с ID {author_id} не найден"}
shout = session.query(Shout).filter(Shout.id == shout_id).first()
if not shout:
return {"success": False, "error": f"Публикация с ID {shout_id} не найдена"}
# Проверяем, не существует ли уже такое приглашение
existing_invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if existing_invite:
return {
"success": False,
"error": f"Приглашение от {inviter.name} для {author.name} на публикацию '{shout.title}' уже существует",
}
# Создаем новое приглашение
new_invite = Invite(
inviter_id=inviter_id,
author_id=author_id,
shout_id=shout_id,
status=status,
)
session.add(new_invite)
session.commit()
logger.info(f"Создано приглашение: {inviter.name} приглашает {author.name} к публикации '{shout.title}'")
return {"success": True, "error": None}
except Exception as e:
logger.error(f"Ошибка при создании приглашения: {e!s}")
msg = f"Не удалось создать приглашение: {e!s}"
raise GraphQLError(msg) from e
@mutation.field("adminUpdateInvite")
@admin_auth_required
async def admin_update_invite(_: None, _info: GraphQLResolveInfo, invite: dict[str, Any]) -> dict[str, Any]:
"""
Обновляет существующее приглашение
Args:
_info: Контекст GraphQL запроса
invite: Данные приглашения для обновления
Returns:
Результат операции
"""
try:
inviter_id = invite["inviter_id"]
author_id = invite["author_id"]
shout_id = invite["shout_id"]
new_status = invite["status"]
with local_session() as session:
# Находим существующее приглашение
existing_invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if not existing_invite:
return {
"success": False,
"error": f"Приглашение с ID {inviter_id}-{author_id}-{shout_id} не найдено",
}
# Обновляем статус
old_status = existing_invite.status
existing_invite.status = new_status
session.commit()
logger.info(f"Обновлён статус приглашения {inviter_id}-{author_id}-{shout_id}: {old_status}{new_status}")
return {"success": True, "error": None}
except Exception as e:
logger.error(f"Ошибка при обновлении приглашения: {e!s}")
msg = f"Не удалось обновить приглашение: {e!s}"
raise GraphQLError(msg) from e
@mutation.field("adminDeleteInvite")
@admin_auth_required
async def admin_delete_invite(
_: None, _info: GraphQLResolveInfo, inviter_id: int, author_id: int, shout_id: int
) -> dict[str, Any]:
"""
Удаляет приглашение
Args:
_info: Контекст GraphQL запроса
inviter_id: ID приглашающего
author_id: ID приглашаемого
shout_id: ID публикации
Returns:
Результат операции
"""
try:
with local_session() as session:
# Находим приглашение для удаления
invite = (
session.query(Invite)
.filter(
Invite.inviter_id == inviter_id,
Invite.author_id == author_id,
Invite.shout_id == shout_id,
)
.first()
)
if not invite:
return {
"success": False,
"error": f"Приглашение с ID {inviter_id}-{author_id}-{shout_id} не найдено",
}
# Удаляем приглашение
session.delete(invite)
session.commit()
logger.info(f"Удалено приглашение {inviter_id}-{author_id}-{shout_id}")
return {"success": True, "error": None}
except Exception as e:
logger.error(f"Ошибка при удалении приглашения: {e!s}")
msg = f"Не удалось удалить приглашение: {e!s}"
raise GraphQLError(msg) from e

View File

@ -113,6 +113,34 @@ input AdminShoutUpdateInput {
deleted_at: Int
}
# Тип для отображения приглашения в админ-панели
type AdminInviteInfo {
inviter_id: Int!
author_id: Int!
shout_id: Int!
status: InviteStatus!
inviter: Author!
author: Author!
shout: AdminShoutInfo!
created_at: Int
}
# Тип для пагинированного ответа приглашений
type AdminInviteListResponse {
invites: [AdminInviteInfo!]!
total: Int!
page: Int!
perPage: Int!
totalPages: Int!
}
input AdminInviteUpdateInput {
inviter_id: Int!
author_id: Int!
shout_id: Int!
status: InviteStatus!
}
extend type Query {
getEnvVariables: [EnvSection!]!
# Запросы для управления пользователями
@ -120,6 +148,8 @@ extend type Query {
adminGetRoles: [Role!]!
# Запросы для управления публикациями
adminGetShouts(limit: Int, offset: Int, search: String, status: String): AdminShoutListResponse!
# Запросы для управления приглашениями
adminGetInvites(limit: Int, offset: Int, search: String, status: String): AdminInviteListResponse!
}
extend type Mutation {
@ -132,4 +162,8 @@ extend type Mutation {
adminUpdateShout(shout: AdminShoutUpdateInput!): OperationResult!
adminDeleteShout(id: Int!): OperationResult!
adminRestoreShout(id: Int!): OperationResult!
# Мутации для управления приглашениями
adminCreateInvite(invite: AdminInviteUpdateInput!): OperationResult!
adminUpdateInvite(invite: AdminInviteUpdateInput!): OperationResult!
adminDeleteInvite(inviter_id: Int!, author_id: Int!, shout_id: Int!): OperationResult!
}

View File

@ -170,6 +170,7 @@ type Collection {
slug: String!
title: String!
desc: String
pic: String
amount: Int
published_at: Int
created_at: Int!