This commit is contained in:
parent
1e2c85e56a
commit
41395eb7c6
85
CHANGELOG.md
85
CHANGELOG.md
|
@ -1,5 +1,47 @@
|
||||||
# Changelog
|
# 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
|
## [0.5.9] - 2025-06-30
|
||||||
|
|
||||||
### Новая функциональность CRUD коллекций
|
### Новая функциональность CRUD коллекций
|
||||||
|
@ -44,6 +86,49 @@
|
||||||
- **Оптимизированная конфигурация маршрутов**: Четкое разделение между API, статикой и SPA fallback
|
- **Оптимизированная конфигурация маршрутов**: Четкое разделение между API, статикой и SPA fallback
|
||||||
- **Совместимость с SolidJS Router**: Полная поддержка клиентского роутинга
|
- **Совместимость с 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
|
## [0.5.8] - 2025-06-30
|
||||||
|
|
||||||
### Улучшения интерфейса публикаций
|
### Улучшения интерфейса публикаций
|
||||||
|
|
|
@ -12,6 +12,7 @@ import AuthorsRoute from './routes/authors'
|
||||||
import CollectionsRoute from './routes/collections'
|
import CollectionsRoute from './routes/collections'
|
||||||
import CommunitiesRoute from './routes/communities'
|
import CommunitiesRoute from './routes/communities'
|
||||||
import EnvRoute from './routes/env'
|
import EnvRoute from './routes/env'
|
||||||
|
import InvitesRoute from './routes/invites'
|
||||||
import ShoutsRoute from './routes/shouts'
|
import ShoutsRoute from './routes/shouts'
|
||||||
import TopicsRoute from './routes/topics'
|
import TopicsRoute from './routes/topics'
|
||||||
import styles from './styles/Admin.module.css'
|
import styles from './styles/Admin.module.css'
|
||||||
|
@ -140,6 +141,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
>
|
>
|
||||||
Коллекции
|
Коллекции
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab() === 'invites' ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => navigate('/admin/invites')}
|
||||||
|
>
|
||||||
|
Приглашения
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab() === 'env' ? 'primary' : 'secondary'}
|
variant={activeTab() === 'env' ? 'primary' : 'secondary'}
|
||||||
onClick={() => navigate('/admin/env')}
|
onClick={() => navigate('/admin/env')}
|
||||||
|
@ -179,6 +186,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
||||||
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
|
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeTab() === 'invites'}>
|
||||||
|
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={activeTab() === 'env'}>
|
<Show when={activeTab() === 'env'}>
|
||||||
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -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 = `
|
export const UPDATE_TOPIC_MUTATION = `
|
||||||
mutation UpdateTopic($topic_input: TopicInput!) {
|
mutation UpdateTopic($topic_input: TopicInput!) {
|
||||||
update_topic(topic_input: $topic_input) {
|
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 = `
|
export const UPDATE_COMMUNITY_MUTATION = `
|
||||||
mutation UpdateCommunity($community_input: CommunityInput!) {
|
mutation UpdateCommunity($community_input: CommunityInput!) {
|
||||||
update_community(community_input: $community_input) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
|
@ -17,15 +17,10 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||||
cover
|
cover
|
||||||
cover_caption
|
cover_caption
|
||||||
media {
|
media {
|
||||||
|
type
|
||||||
url
|
url
|
||||||
title
|
|
||||||
body
|
body
|
||||||
source
|
caption
|
||||||
pic
|
|
||||||
date
|
|
||||||
genre
|
|
||||||
artist
|
|
||||||
lyrics
|
|
||||||
}
|
}
|
||||||
seo
|
seo
|
||||||
created_at
|
created_at
|
||||||
|
@ -35,23 +30,45 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||||
deleted_at
|
deleted_at
|
||||||
created_by {
|
created_by {
|
||||||
id
|
id
|
||||||
email
|
|
||||||
name
|
name
|
||||||
|
email
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
updated_by {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
deleted_by {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
community {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
}
|
}
|
||||||
authors {
|
authors {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
email
|
email
|
||||||
|
slug
|
||||||
}
|
}
|
||||||
topics {
|
topics {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
slug
|
slug
|
||||||
}
|
}
|
||||||
|
version_of
|
||||||
|
draft
|
||||||
stat {
|
stat {
|
||||||
rating
|
rating
|
||||||
comments_count
|
comments_count
|
||||||
viewed
|
viewed
|
||||||
|
last_commented_at
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
total
|
total
|
||||||
|
@ -115,21 +132,24 @@ export const GET_COMMUNITIES_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query GetCommunities {
|
query GetCommunities {
|
||||||
get_communities_all {
|
get_communities_all {
|
||||||
id
|
communities {
|
||||||
slug
|
|
||||||
name
|
|
||||||
desc
|
|
||||||
pic
|
|
||||||
created_at
|
|
||||||
created_by {
|
|
||||||
id
|
id
|
||||||
|
slug
|
||||||
name
|
name
|
||||||
email
|
desc
|
||||||
}
|
pic
|
||||||
stat {
|
created_at
|
||||||
shouts
|
created_by {
|
||||||
followers
|
id
|
||||||
authors
|
name
|
||||||
|
email
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
stat {
|
||||||
|
shouts
|
||||||
|
followers
|
||||||
|
authors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,17 +159,22 @@ export const GET_TOPICS_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query GetTopics {
|
query GetTopics {
|
||||||
get_topics_all {
|
get_topics_all {
|
||||||
id
|
topics {
|
||||||
slug
|
id
|
||||||
title
|
slug
|
||||||
body
|
title
|
||||||
pic
|
body
|
||||||
community
|
pic
|
||||||
parent_ids
|
community
|
||||||
stat {
|
parent_ids
|
||||||
shouts
|
stat {
|
||||||
authors
|
shouts
|
||||||
followers
|
followers
|
||||||
|
authors
|
||||||
|
comments
|
||||||
|
}
|
||||||
|
oid
|
||||||
|
is_main
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,19 +184,64 @@ export const GET_COLLECTIONS_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query GetCollections {
|
query GetCollections {
|
||||||
get_collections_all {
|
get_collections_all {
|
||||||
id
|
collections {
|
||||||
slug
|
|
||||||
title
|
|
||||||
desc
|
|
||||||
pic
|
|
||||||
amount
|
|
||||||
created_at
|
|
||||||
published_at
|
|
||||||
created_by {
|
|
||||||
id
|
id
|
||||||
name
|
slug
|
||||||
email
|
title
|
||||||
|
desc
|
||||||
|
pic
|
||||||
|
amount
|
||||||
|
published_at
|
||||||
|
created_at
|
||||||
|
created_by {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
|
slug
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`.loc?.source.body || ''
|
`.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 || ''
|
||||||
|
|
187
panel/modals/CollectionEditModal.tsx
Normal file
187
panel/modals/CollectionEditModal.tsx
Normal 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
|
192
panel/modals/CommunityEditModal.tsx
Normal file
192
panel/modals/CommunityEditModal.tsx
Normal 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
|
234
panel/modals/InviteEditModal.tsx
Normal file
234
panel/modals/InviteEditModal.tsx
Normal 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
|
|
@ -5,6 +5,7 @@ import {
|
||||||
UPDATE_COLLECTION_MUTATION
|
UPDATE_COLLECTION_MUTATION
|
||||||
} from '../graphql/mutations'
|
} from '../graphql/mutations'
|
||||||
import { GET_COLLECTIONS_QUERY } from '../graphql/queries'
|
import { GET_COLLECTIONS_QUERY } from '../graphql/queries'
|
||||||
|
import CollectionEditModal from '../modals/CollectionEditModal'
|
||||||
import styles from '../styles/Table.module.css'
|
import styles from '../styles/Table.module.css'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
@ -49,14 +50,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
||||||
})
|
})
|
||||||
const [createModal, setCreateModal] = createSignal(false)
|
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) => {
|
const openEditModal = (collection: Collection) => {
|
||||||
setFormData({
|
|
||||||
slug: collection.slug,
|
|
||||||
title: collection.title,
|
|
||||||
desc: collection.desc || '',
|
|
||||||
pic: collection.pic
|
|
||||||
})
|
|
||||||
setEditModal({ show: true, collection })
|
setEditModal({ show: true, collection })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,28 +98,25 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
||||||
* Открывает модалку создания
|
* Открывает модалку создания
|
||||||
*/
|
*/
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
setFormData({
|
|
||||||
slug: '',
|
|
||||||
title: '',
|
|
||||||
desc: '',
|
|
||||||
pic: ''
|
|
||||||
})
|
|
||||||
setCreateModal(true)
|
setCreateModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создает новую коллекцию
|
* Обрабатывает сохранение коллекции (создание или обновление)
|
||||||
*/
|
*/
|
||||||
const createCollection = async () => {
|
const handleSaveCollection = async (collectionData: Partial<Collection>) => {
|
||||||
try {
|
try {
|
||||||
|
const isCreating = createModal()
|
||||||
|
const mutation = isCreating ? CREATE_COLLECTION_MUTATION : UPDATE_COLLECTION_MUTATION
|
||||||
|
|
||||||
const response = await fetch('/graphql', {
|
const response = await fetch('/graphql', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: CREATE_COLLECTION_MUTATION,
|
query: mutation,
|
||||||
variables: { collection_input: formData() }
|
variables: { collection_input: collectionData }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -142,49 +126,19 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
||||||
throw new Error(result.errors[0].message)
|
throw new Error(result.errors[0].message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.create_collection.error) {
|
const resultData = isCreating ? result.data.create_collection : result.data.update_collection
|
||||||
throw new Error(result.data.create_collection.error)
|
if (resultData.error) {
|
||||||
|
throw new Error(resultData.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
props.onSuccess('Коллекция успешно создана')
|
props.onSuccess(isCreating ? 'Коллекция успешно создана' : 'Коллекция успешно обновлена')
|
||||||
setCreateModal(false)
|
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 })
|
setEditModal({ show: false, collection: null })
|
||||||
await loadCollections()
|
await loadCollections()
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<div class={styles.header}>
|
<div class={styles.header}>
|
||||||
<h2>Управление коллекциями</h2>
|
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
<Button onClick={openCreateModal} variant="primary">
|
<Button onClick={openCreateModal} variant="primary">
|
||||||
Создать коллекцию
|
Создать коллекцию
|
||||||
|
@ -313,181 +266,20 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Модальное окно создания */}
|
{/* Модальное окно создания */}
|
||||||
<Modal isOpen={createModal()} onClose={() => setCreateModal(false)} title="Создание новой коллекции">
|
<CollectionEditModal
|
||||||
<div style={{ padding: '20px' }}>
|
isOpen={createModal()}
|
||||||
<div style={{ 'margin-bottom': '16px' }}>
|
collection={null}
|
||||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
onClose={() => setCreateModal(false)}
|
||||||
Slug <span style={{ color: 'red' }}>*</span>
|
onSave={handleSaveCollection}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
{/* Модальное окно редактирования */}
|
||||||
<Modal
|
<CollectionEditModal
|
||||||
isOpen={editModal().show}
|
isOpen={editModal().show}
|
||||||
|
collection={editModal().collection}
|
||||||
onClose={() => setEditModal({ show: false, collection: null })}
|
onClose={() => setEditModal({ show: false, collection: null })}
|
||||||
title={`Редактирование коллекции: ${editModal().collection?.title || ''}`}
|
onSave={handleSaveCollection}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Модальное окно подтверждения удаления */}
|
{/* Модальное окно подтверждения удаления */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
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 { GET_COMMUNITIES_QUERY } from '../graphql/queries'
|
||||||
|
import CommunityEditModal from '../modals/CommunityEditModal'
|
||||||
import styles from '../styles/Table.module.css'
|
import styles from '../styles/Table.module.css'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
@ -46,13 +51,8 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||||
show: false,
|
show: false,
|
||||||
community: null
|
community: null
|
||||||
})
|
})
|
||||||
|
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
||||||
// Форма для редактирования
|
show: false
|
||||||
const [formData, setFormData] = createSignal({
|
|
||||||
slug: '',
|
|
||||||
name: '',
|
|
||||||
desc: '',
|
|
||||||
pic: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -92,32 +92,36 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||||
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
|
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает модалку создания
|
||||||
|
*/
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setCreateModal({ show: true })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Открывает модалку редактирования
|
* Открывает модалку редактирования
|
||||||
*/
|
*/
|
||||||
const openEditModal = (community: Community) => {
|
const openEditModal = (community: Community) => {
|
||||||
setFormData({
|
|
||||||
slug: community.slug,
|
|
||||||
name: community.name,
|
|
||||||
desc: community.desc || '',
|
|
||||||
pic: community.pic
|
|
||||||
})
|
|
||||||
setEditModal({ show: true, community })
|
setEditModal({ show: true, community })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет сообщество
|
* Обрабатывает сохранение сообщества (создание или обновление)
|
||||||
*/
|
*/
|
||||||
const updateCommunity = async () => {
|
const handleSaveCommunity = async (communityData: Partial<Community>) => {
|
||||||
try {
|
try {
|
||||||
|
const isCreating = !editModal().community && createModal().show
|
||||||
|
const mutation = isCreating ? CREATE_COMMUNITY_MUTATION : UPDATE_COMMUNITY_MUTATION
|
||||||
|
|
||||||
const response = await fetch('/graphql', {
|
const response = await fetch('/graphql', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: UPDATE_COMMUNITY_MUTATION,
|
query: mutation,
|
||||||
variables: { community_input: formData() }
|
variables: { community_input: communityData }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -127,15 +131,19 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||||
throw new Error(result.errors[0].message)
|
throw new Error(result.errors[0].message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.update_community.error) {
|
const resultData = isCreating ? result.data.create_community : result.data.update_community
|
||||||
throw new Error(result.data.update_community.error)
|
if (resultData.error) {
|
||||||
|
throw new Error(resultData.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
props.onSuccess('Сообщество успешно обновлено')
|
props.onSuccess(isCreating ? 'Сообщество успешно создано' : 'Сообщество успешно обновлено')
|
||||||
|
setCreateModal({ show: false })
|
||||||
setEditModal({ show: false, community: null })
|
setEditModal({ show: false, community: null })
|
||||||
await loadCommunities()
|
await loadCommunities()
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<div class={styles.header}>
|
<div class={styles.header}>
|
||||||
<h2>Управление сообществами</h2>
|
|
||||||
<Button onClick={loadCommunities} disabled={loading()}>
|
<Button onClick={loadCommunities} disabled={loading()}>
|
||||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="primary" onClick={openCreateModal}>
|
||||||
|
Создать сообщество
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
|
@ -260,93 +270,21 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
||||||
</table>
|
</table>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Модальное окно создания */}
|
||||||
|
<CommunityEditModal
|
||||||
|
isOpen={createModal().show}
|
||||||
|
community={null}
|
||||||
|
onClose={() => setCreateModal({ show: false })}
|
||||||
|
onSave={handleSaveCommunity}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
{/* Модальное окно редактирования */}
|
||||||
<Modal
|
<CommunityEditModal
|
||||||
isOpen={editModal().show}
|
isOpen={editModal().show}
|
||||||
|
community={editModal().community}
|
||||||
onClose={() => setEditModal({ show: false, community: null })}
|
onClose={() => setEditModal({ show: false, community: null })}
|
||||||
title={`Редактирование сообщества: ${editModal().community?.name || ''}`}
|
onSave={handleSaveCommunity}
|
||||||
>
|
/>
|
||||||
<div style={{ padding: '20px' }}>
|
|
||||||
<div style={{ 'margin-bottom': '16px' }}>
|
|
||||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData().slug}
|
|
||||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
'border-radius': '4px'
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ 'margin-bottom': '16px' }}>
|
|
||||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
|
||||||
Название
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData().name}
|
|
||||||
onInput={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
'border-radius': '4px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ 'margin-bottom': '16px' }}>
|
|
||||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
|
||||||
Описание
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData().desc}
|
|
||||||
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
'border-radius': '4px',
|
|
||||||
'min-height': '80px',
|
|
||||||
resize: 'vertical'
|
|
||||||
}}
|
|
||||||
placeholder="Описание сообщества..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ 'margin-bottom': '16px' }}>
|
|
||||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
|
||||||
Картинка (URL)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData().pic}
|
|
||||||
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
'border-radius': '4px'
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com/image.jpg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={styles['modal-actions']}>
|
|
||||||
<Button variant="secondary" onClick={() => setEditModal({ show: false, community: null })}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" onClick={updateCommunity}>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Модальное окно подтверждения удаления */}
|
{/* Модальное окно подтверждения удаления */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|
424
panel/routes/invites.tsx
Normal file
424
panel/routes/invites.tsx
Normal 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
|
|
@ -6,7 +6,7 @@
|
||||||
import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js'
|
import { Component, createEffect, createSignal, For, JSX, on, onMount, Show, untrack } from 'solid-js'
|
||||||
import { query } from '../graphql'
|
import { query } from '../graphql'
|
||||||
import type { Query } from '../graphql/generated/schema'
|
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 { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||||
import TopicEditModal from '../modals/TopicEditModal'
|
import TopicEditModal from '../modals/TopicEditModal'
|
||||||
import styles from '../styles/Table.module.css'
|
import styles from '../styles/Table.module.css'
|
||||||
|
@ -53,6 +53,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
show: false,
|
show: false,
|
||||||
topic: null
|
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 (
|
return (
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<div class={styles.header}>
|
<div class={styles.header}>
|
||||||
<h2>Управление топиками</h2>
|
|
||||||
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
|
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
|
||||||
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
||||||
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
|
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
|
||||||
|
@ -339,6 +375,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
<Button onClick={loadTopics} disabled={loading()}>
|
<Button onClick={loadTopics} disabled={loading()}>
|
||||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
|
||||||
|
Создать тему
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -369,6 +408,14 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||||
</table>
|
</table>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Модальное окно создания */}
|
||||||
|
<TopicEditModal
|
||||||
|
isOpen={createModal().show}
|
||||||
|
topic={null}
|
||||||
|
onClose={() => setCreateModal({ show: false })}
|
||||||
|
onSave={createTopic}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
{/* Модальное окно редактирования */}
|
||||||
<TopicEditModal
|
<TopicEditModal
|
||||||
isOpen={editModal().show}
|
isOpen={editModal().show}
|
||||||
|
|
|
@ -9,6 +9,7 @@ from sqlalchemy.sql import func, select
|
||||||
|
|
||||||
from auth.decorators import admin_auth_required
|
from auth.decorators import admin_auth_required
|
||||||
from auth.orm import Author, AuthorRole, Role
|
from auth.orm import Author, AuthorRole, Role
|
||||||
|
from orm.invite import Invite, InviteStatus
|
||||||
from orm.shout import Shout
|
from orm.shout import Shout
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.env import EnvManager, EnvVariable
|
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}"
|
error_msg = f"Ошибка при восстановлении публикации: {e!s}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"success": False, "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
|
||||||
|
|
|
@ -113,6 +113,34 @@ input AdminShoutUpdateInput {
|
||||||
deleted_at: Int
|
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 {
|
extend type Query {
|
||||||
getEnvVariables: [EnvSection!]!
|
getEnvVariables: [EnvSection!]!
|
||||||
# Запросы для управления пользователями
|
# Запросы для управления пользователями
|
||||||
|
@ -120,6 +148,8 @@ extend type Query {
|
||||||
adminGetRoles: [Role!]!
|
adminGetRoles: [Role!]!
|
||||||
# Запросы для управления публикациями
|
# Запросы для управления публикациями
|
||||||
adminGetShouts(limit: Int, offset: Int, search: String, status: String): AdminShoutListResponse!
|
adminGetShouts(limit: Int, offset: Int, search: String, status: String): AdminShoutListResponse!
|
||||||
|
# Запросы для управления приглашениями
|
||||||
|
adminGetInvites(limit: Int, offset: Int, search: String, status: String): AdminInviteListResponse!
|
||||||
}
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
|
@ -132,4 +162,8 @@ extend type Mutation {
|
||||||
adminUpdateShout(shout: AdminShoutUpdateInput!): OperationResult!
|
adminUpdateShout(shout: AdminShoutUpdateInput!): OperationResult!
|
||||||
adminDeleteShout(id: Int!): OperationResult!
|
adminDeleteShout(id: Int!): OperationResult!
|
||||||
adminRestoreShout(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!
|
||||||
}
|
}
|
||||||
|
|
|
@ -170,6 +170,7 @@ type Collection {
|
||||||
slug: String!
|
slug: String!
|
||||||
title: String!
|
title: String!
|
||||||
desc: String
|
desc: String
|
||||||
|
pic: String
|
||||||
amount: Int
|
amount: Int
|
||||||
published_at: Int
|
published_at: Int
|
||||||
created_at: Int!
|
created_at: Int!
|
||||||
|
|
Loading…
Reference in New Issue
Block a user