This commit is contained in:
parent
1e2c85e56a
commit
41395eb7c6
85
CHANGELOG.md
85
CHANGELOG.md
|
@ -1,5 +1,47 @@
|
|||
# Changelog
|
||||
|
||||
## [0.5.10] - 2025-06-30
|
||||
|
||||
### Новая функциональность CRUD приглашений
|
||||
|
||||
- **НОВОЕ**: Полноценное управление приглашениями в админ-панели:
|
||||
- **Новая вкладка "Приглашения"**: Отдельная секция в админ-панели для управления приглашениями к сотрудничеству
|
||||
- **Полная CRUD функциональность**: Создание, редактирование, удаление приглашений
|
||||
- **Подробная таблица**: Приглашающий, приглашаемый, публикация, статус с детальной информацией
|
||||
- **Клик для редактирования**: Нажатие на строку открывает модалку редактирования приглашения
|
||||
- **Удаление с подтверждением**: Тонкая кнопка "×" для удаления с модальным окном подтверждения
|
||||
- **Кнопка создания**: Возможность создания новых приглашений прямо из интерфейса
|
||||
- **Фильтрация по статусу**: Все/Ожидает ответа/Принято/Отклонено
|
||||
- **Поиск**: По email и именам приглашающего/приглашаемого, названию публикации, ID
|
||||
- **Пагинация**: Полная поддержка пагинации для больших списков приглашений
|
||||
|
||||
- **Серверная часть**:
|
||||
- **GraphQL схема**: Новые queries, mutations и input types для приглашений:
|
||||
- `adminGetInvites` - получение списка приглашений с фильтрацией и пагинацией
|
||||
- `adminCreateInvite` - создание нового приглашения
|
||||
- `adminUpdateInvite` - обновление статуса приглашения
|
||||
- `adminDeleteInvite` - удаление приглашения
|
||||
- **Резолверы**: Полный набор администраторских резолверов с проверкой прав доступа
|
||||
- **Авторизация**: Требуется роль admin для создания/редактирования/удаления приглашений
|
||||
- **Валидация данных**: Проверка существования всех связанных объектов (авторы, публикации)
|
||||
- **Предотвращение дублирования**: Проверка уникальности приглашений по составному ключу
|
||||
- **Подробное логирование**: Отслеживание всех операций с приглашениями для аудита
|
||||
|
||||
- **Архитектурные улучшения**:
|
||||
- **Модальное окно InviteEditModal**: Отдельный компонент для создания/редактирования приглашений
|
||||
- **Автоматическое определение режима**: Модальное окно само определяет режим создания/редактирования
|
||||
- **Валидация форм**: Проверка корректности ID, предотвращение самоприглашений
|
||||
- **Составной первичный ключ**: Работа с уникальным идентификатором из трех полей (inviter_id, author_id, shout_id)
|
||||
- **Статусные бейджи**: Цветовая индикация статусов (ожидает/принято/отклонено)
|
||||
- **Информационные панели**: Отображение полной информации о связанных авторах и публикациях
|
||||
|
||||
- **ТЕХНИЧЕСКАЯ АРХИТЕКТУРА**:
|
||||
- **Следование паттернам проекта**: Использование существующих компонентов Button, Modal, Pagination
|
||||
- **Переиспользование стилей**: CSS модули Table.module.css, Form.module.css, Modal.module.css
|
||||
- **Консистентный API**: Единый стиль GraphQL операций admin* с другими админскими функциями
|
||||
- **TypeScript типизация**: Полная типизация всех интерфейсов приглашений и связанных объектов
|
||||
- **Обработка ошибок**: Централизованная обработка ошибок с детальными сообщениями пользователю
|
||||
|
||||
## [0.5.9] - 2025-06-30
|
||||
|
||||
### Новая функциональность CRUD коллекций
|
||||
|
@ -44,6 +86,49 @@
|
|||
- **Оптимизированная конфигурация маршрутов**: Четкое разделение между API, статикой и SPA fallback
|
||||
- **Совместимость с SolidJS Router**: Полная поддержка клиентского роутинга
|
||||
|
||||
### Исправления GraphQL схемы и расширение CRUD
|
||||
|
||||
- **ИСПРАВЛЕНО**: Поле `pic` в типе Collection:
|
||||
- **Проблема**: GraphQL ошибка "Cannot query field 'pic' on type 'Collection'"
|
||||
- **Решение**: Добавлено поле `pic: String` в тип Collection в `schema/type.graphql`
|
||||
- **Результат**: Картинки коллекций корректно отображаются в админ-панели
|
||||
|
||||
- **НОВОЕ**: Полноценный CRUD для тем и сообществ:
|
||||
- **Кнопки создания**: Добавлены кнопки "Создать тему" и "Создать сообщество" в соответствующие разделы админ-панели
|
||||
- **Мутации создания**:
|
||||
- `CREATE_TOPIC_MUTATION` для создания новых тем
|
||||
- `CREATE_COMMUNITY_MUTATION` для создания новых сообществ
|
||||
- **Модальные окна создания**: Полнофункциональные формы с валидацией для создания тем и сообществ
|
||||
- **Интеграция с существующими резолверами**: Использование GraphQL мутаций `create_topic` и `create_community`
|
||||
- **Результат**: Администраторы могут создавать новые темы и сообщества прямо из админ-панели
|
||||
|
||||
- **Архитектурные улучшения**:
|
||||
- **Переиспользование компонентов**: TopicEditModal используется как для создания, так и для редактирования тем
|
||||
- **Консистентный UX**: Единый стиль модальных окон создания/редактирования для всех сущностей
|
||||
- **Валидация форм**: Обязательные поля (slug, name) с placeholder'ами и подсказками
|
||||
- **Автоматическое обновление**: После создания/редактирования списки автоматически перезагружаются
|
||||
|
||||
### Рефакторинг модальных окон
|
||||
|
||||
- **РЕФАКТОРИНГ**: Изоляция модальных окон в отдельные компоненты:
|
||||
- **Проблема**: Модальные окна создания/редактирования находились прямо в компонентах маршрутов, нарушая принцип разделения ответственности
|
||||
- **Решение**: Создание отдельных компонентов в папке `@/modals`:
|
||||
- `CommunityEditModal.tsx` - для создания и редактирования сообществ
|
||||
- `CollectionEditModal.tsx` - для создания и редактирования коллекций
|
||||
- **Архитектурные улучшения**:
|
||||
- **Следование традициям проекта**: Все модальные окна теперь изолированы в отдельные компоненты (`EnvVariableModal`, `RolesModal`, `ShoutBodyModal`, `TopicEditModal`)
|
||||
- **Переиспользование паттернов**: Единый стиль props, валидации и обработки ошибок
|
||||
- **Лучшая типизация**: TypeScript интерфейсы для всех props компонентов
|
||||
- **Упрощение роутов**: Убрана сложная логика форм из маршрутов - теперь только логика API вызовов
|
||||
- **Валидация форм**: Централизованная валидация в модальных компонентах с real-time обратной связью
|
||||
- **Результат**: Более чистая архитектура, лучшее разделение ответственности, упрощение тестирования
|
||||
|
||||
- **ТЕХНИЧЕСКАЯ АРХИТЕКТУРА**:
|
||||
- **Унификация API**: Единый паттерн `onSave(data: Partial<Entity>)` для всех модальных окон создания/редактирования
|
||||
- **Автоматическое определение режима**: Модальные окна сами определяют режим создания/редактирования по наличию entity в props
|
||||
- **Очистка состояния**: Автоматический сброс ошибок и формы при открытии/закрытии модальных окон
|
||||
- **Консистентные стили**: Переиспользование CSS модулей `Form.module.css` и `Modal.module.css`
|
||||
|
||||
## [0.5.8] - 2025-06-30
|
||||
|
||||
### Улучшения интерфейса публикаций
|
||||
|
|
|
@ -12,6 +12,7 @@ import AuthorsRoute from './routes/authors'
|
|||
import CollectionsRoute from './routes/collections'
|
||||
import CommunitiesRoute from './routes/communities'
|
||||
import EnvRoute from './routes/env'
|
||||
import InvitesRoute from './routes/invites'
|
||||
import ShoutsRoute from './routes/shouts'
|
||||
import TopicsRoute from './routes/topics'
|
||||
import styles from './styles/Admin.module.css'
|
||||
|
@ -140,6 +141,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
>
|
||||
Коллекции
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab() === 'invites' ? 'primary' : 'secondary'}
|
||||
onClick={() => navigate('/admin/invites')}
|
||||
>
|
||||
Приглашения
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab() === 'env' ? 'primary' : 'secondary'}
|
||||
onClick={() => navigate('/admin/env')}
|
||||
|
@ -179,6 +186,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'invites'}>
|
||||
<InvitesRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'env'}>
|
||||
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
|
|
@ -30,6 +30,14 @@ export const ADMIN_UPDATE_ENV_VARIABLE_MUTATION = `
|
|||
}
|
||||
`
|
||||
|
||||
export const CREATE_TOPIC_MUTATION = `
|
||||
mutation CreateTopic($topic_input: TopicInput!) {
|
||||
create_topic(topic_input: $topic_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_TOPIC_MUTATION = `
|
||||
mutation UpdateTopic($topic_input: TopicInput!) {
|
||||
update_topic(topic_input: $topic_input) {
|
||||
|
@ -46,6 +54,14 @@ export const DELETE_TOPIC_MUTATION = `
|
|||
}
|
||||
`
|
||||
|
||||
export const CREATE_COMMUNITY_MUTATION = `
|
||||
mutation CreateCommunity($community_input: CommunityInput!) {
|
||||
create_community(community_input: $community_input) {
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_COMMUNITY_MUTATION = `
|
||||
mutation UpdateCommunity($community_input: CommunityInput!) {
|
||||
update_community(community_input: $community_input) {
|
||||
|
@ -85,3 +101,30 @@ export const DELETE_COLLECTION_MUTATION = `
|
|||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_CREATE_INVITE_MUTATION = `
|
||||
mutation AdminCreateInvite($invite: AdminInviteUpdateInput!) {
|
||||
adminCreateInvite(invite: $invite) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_UPDATE_INVITE_MUTATION = `
|
||||
mutation AdminUpdateInvite($invite: AdminInviteUpdateInput!) {
|
||||
adminUpdateInvite(invite: $invite) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_DELETE_INVITE_MUTATION = `
|
||||
mutation AdminDeleteInvite($inviter_id: Int!, $author_id: Int!, $shout_id: Int!) {
|
||||
adminDeleteInvite(inviter_id: $inviter_id, author_id: $author_id, shout_id: $shout_id) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -17,15 +17,10 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
|||
cover
|
||||
cover_caption
|
||||
media {
|
||||
type
|
||||
url
|
||||
title
|
||||
body
|
||||
source
|
||||
pic
|
||||
date
|
||||
genre
|
||||
artist
|
||||
lyrics
|
||||
caption
|
||||
}
|
||||
seo
|
||||
created_at
|
||||
|
@ -35,23 +30,45 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
|||
deleted_at
|
||||
created_by {
|
||||
id
|
||||
email
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
updated_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
deleted_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
community {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
authors {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
topics {
|
||||
id
|
||||
title
|
||||
slug
|
||||
}
|
||||
version_of
|
||||
draft
|
||||
stat {
|
||||
rating
|
||||
comments_count
|
||||
viewed
|
||||
last_commented_at
|
||||
}
|
||||
}
|
||||
total
|
||||
|
@ -115,6 +132,7 @@ export const GET_COMMUNITIES_QUERY: string =
|
|||
gql`
|
||||
query GetCommunities {
|
||||
get_communities_all {
|
||||
communities {
|
||||
id
|
||||
slug
|
||||
name
|
||||
|
@ -125,6 +143,7 @@ export const GET_COMMUNITIES_QUERY: string =
|
|||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
stat {
|
||||
shouts
|
||||
|
@ -133,12 +152,14 @@ export const GET_COMMUNITIES_QUERY: string =
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const GET_TOPICS_QUERY: string =
|
||||
gql`
|
||||
query GetTopics {
|
||||
get_topics_all {
|
||||
topics {
|
||||
id
|
||||
slug
|
||||
title
|
||||
|
@ -148,8 +169,12 @@ export const GET_TOPICS_QUERY: string =
|
|||
parent_ids
|
||||
stat {
|
||||
shouts
|
||||
authors
|
||||
followers
|
||||
authors
|
||||
comments
|
||||
}
|
||||
oid
|
||||
is_main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -159,19 +184,64 @@ export const GET_COLLECTIONS_QUERY: string =
|
|||
gql`
|
||||
query GetCollections {
|
||||
get_collections_all {
|
||||
collections {
|
||||
id
|
||||
slug
|
||||
title
|
||||
desc
|
||||
pic
|
||||
amount
|
||||
created_at
|
||||
published_at
|
||||
created_at
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
||||
export const ADMIN_GET_INVITES_QUERY: string =
|
||||
gql`
|
||||
query AdminGetInvites($limit: Int, $offset: Int, $search: String, $status: String) {
|
||||
adminGetInvites(limit: $limit, offset: $offset, search: $search, status: $status) {
|
||||
invites {
|
||||
inviter_id
|
||||
author_id
|
||||
shout_id
|
||||
status
|
||||
inviter {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
author {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
shout {
|
||||
id
|
||||
title
|
||||
slug
|
||||
created_by {
|
||||
id
|
||||
name
|
||||
email
|
||||
slug
|
||||
}
|
||||
}
|
||||
created_at
|
||||
}
|
||||
total
|
||||
page
|
||||
perPage
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`.loc?.source.body || ''
|
||||
|
|
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
|
||||
} from '../graphql/mutations'
|
||||
import { GET_COLLECTIONS_QUERY } from '../graphql/queries'
|
||||
import CollectionEditModal from '../modals/CollectionEditModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
@ -49,14 +50,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
|||
})
|
||||
const [createModal, setCreateModal] = createSignal(false)
|
||||
|
||||
// Форма для редактирования/создания
|
||||
const [formData, setFormData] = createSignal({
|
||||
slug: '',
|
||||
title: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Загружает список всех коллекций
|
||||
*/
|
||||
|
@ -98,12 +91,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
|||
* Открывает модалку редактирования
|
||||
*/
|
||||
const openEditModal = (collection: Collection) => {
|
||||
setFormData({
|
||||
slug: collection.slug,
|
||||
title: collection.title,
|
||||
desc: collection.desc || '',
|
||||
pic: collection.pic
|
||||
})
|
||||
setEditModal({ show: true, collection })
|
||||
}
|
||||
|
||||
|
@ -111,28 +98,25 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
|||
* Открывает модалку создания
|
||||
*/
|
||||
const openCreateModal = () => {
|
||||
setFormData({
|
||||
slug: '',
|
||||
title: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
setCreateModal(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает новую коллекцию
|
||||
* Обрабатывает сохранение коллекции (создание или обновление)
|
||||
*/
|
||||
const createCollection = async () => {
|
||||
const handleSaveCollection = async (collectionData: Partial<Collection>) => {
|
||||
try {
|
||||
const isCreating = createModal()
|
||||
const mutation = isCreating ? CREATE_COLLECTION_MUTATION : UPDATE_COLLECTION_MUTATION
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: CREATE_COLLECTION_MUTATION,
|
||||
variables: { collection_input: formData() }
|
||||
query: mutation,
|
||||
variables: { collection_input: collectionData }
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -142,49 +126,19 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
|||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.create_collection.error) {
|
||||
throw new Error(result.data.create_collection.error)
|
||||
const resultData = isCreating ? result.data.create_collection : result.data.update_collection
|
||||
if (resultData.error) {
|
||||
throw new Error(resultData.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Коллекция успешно создана')
|
||||
props.onSuccess(isCreating ? 'Коллекция успешно создана' : 'Коллекция успешно обновлена')
|
||||
setCreateModal(false)
|
||||
await loadCollections()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка создания коллекции: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет коллекцию
|
||||
*/
|
||||
const updateCollection = async () => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: UPDATE_COLLECTION_MUTATION,
|
||||
variables: { collection_input: formData() }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.update_collection.error) {
|
||||
throw new Error(result.data.update_collection.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Коллекция успешно обновлена')
|
||||
setEditModal({ show: false, collection: null })
|
||||
await loadCollections()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка обновления коллекции: ${(error as Error).message}`)
|
||||
props.onError(
|
||||
`Ошибка ${createModal() ? 'создания' : 'обновления'} коллекции: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,7 +184,6 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
|||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление коллекциями</h2>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<Button onClick={openCreateModal} variant="primary">
|
||||
Создать коллекцию
|
||||
|
@ -313,181 +266,20 @@ const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
{/* Модальное окно создания */}
|
||||
<Modal isOpen={createModal()} onClose={() => setCreateModal(false)} title="Создание новой коллекции">
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
|
||||
Slug <span style={{ color: 'red' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().slug}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
required
|
||||
placeholder="my-collection"
|
||||
<CollectionEditModal
|
||||
isOpen={createModal()}
|
||||
collection={null}
|
||||
onClose={() => setCreateModal(false)}
|
||||
onSave={handleSaveCollection}
|
||||
/>
|
||||
</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}
|
||||
collection={editModal().collection}
|
||||
onClose={() => setEditModal({ show: false, collection: null })}
|
||||
title={`Редактирование коллекции: ${editModal().collection?.title || ''}`}
|
||||
>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().slug}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
required
|
||||
onSave={handleSaveCollection}
|
||||
/>
|
||||
</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
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { DELETE_COMMUNITY_MUTATION, UPDATE_COMMUNITY_MUTATION } from '../graphql/mutations'
|
||||
import {
|
||||
CREATE_COMMUNITY_MUTATION,
|
||||
DELETE_COMMUNITY_MUTATION,
|
||||
UPDATE_COMMUNITY_MUTATION
|
||||
} from '../graphql/mutations'
|
||||
import { GET_COMMUNITIES_QUERY } from '../graphql/queries'
|
||||
import CommunityEditModal from '../modals/CommunityEditModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
@ -46,13 +51,8 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
|||
show: false,
|
||||
community: null
|
||||
})
|
||||
|
||||
// Форма для редактирования
|
||||
const [formData, setFormData] = createSignal({
|
||||
slug: '',
|
||||
name: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
||||
show: false
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -92,32 +92,36 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
|||
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модалку создания
|
||||
*/
|
||||
const openCreateModal = () => {
|
||||
setCreateModal({ show: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модалку редактирования
|
||||
*/
|
||||
const openEditModal = (community: Community) => {
|
||||
setFormData({
|
||||
slug: community.slug,
|
||||
name: community.name,
|
||||
desc: community.desc || '',
|
||||
pic: community.pic
|
||||
})
|
||||
setEditModal({ show: true, community })
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет сообщество
|
||||
* Обрабатывает сохранение сообщества (создание или обновление)
|
||||
*/
|
||||
const updateCommunity = async () => {
|
||||
const handleSaveCommunity = async (communityData: Partial<Community>) => {
|
||||
try {
|
||||
const isCreating = !editModal().community && createModal().show
|
||||
const mutation = isCreating ? CREATE_COMMUNITY_MUTATION : UPDATE_COMMUNITY_MUTATION
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: UPDATE_COMMUNITY_MUTATION,
|
||||
variables: { community_input: formData() }
|
||||
query: mutation,
|
||||
variables: { community_input: communityData }
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -127,15 +131,19 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
|||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.update_community.error) {
|
||||
throw new Error(result.data.update_community.error)
|
||||
const resultData = isCreating ? result.data.create_community : result.data.update_community
|
||||
if (resultData.error) {
|
||||
throw new Error(resultData.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Сообщество успешно обновлено')
|
||||
props.onSuccess(isCreating ? 'Сообщество успешно создано' : 'Сообщество успешно обновлено')
|
||||
setCreateModal({ show: false })
|
||||
setEditModal({ show: false, community: null })
|
||||
await loadCommunities()
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка обновления сообщества: ${(error as Error).message}`)
|
||||
props.onError(
|
||||
`Ошибка ${createModal().show ? 'создания' : 'обновления'} сообщества: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,10 +189,12 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
|||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление сообществами</h2>
|
||||
<Button onClick={loadCommunities} disabled={loading()}>
|
||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={openCreateModal}>
|
||||
Создать сообщество
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
|
@ -260,93 +270,21 @@ const CommunitiesRoute: Component<CommunitiesRouteProps> = (props) => {
|
|||
</table>
|
||||
</Show>
|
||||
|
||||
{/* Модальное окно создания */}
|
||||
<CommunityEditModal
|
||||
isOpen={createModal().show}
|
||||
community={null}
|
||||
onClose={() => setCreateModal({ show: false })}
|
||||
onSave={handleSaveCommunity}
|
||||
/>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<Modal
|
||||
<CommunityEditModal
|
||||
isOpen={editModal().show}
|
||||
community={editModal().community}
|
||||
onClose={() => setEditModal({ show: false, community: null })}
|
||||
title={`Редактирование сообщества: ${editModal().community?.name || ''}`}
|
||||
>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ 'margin-bottom': '16px' }}>
|
||||
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().slug}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '4px'
|
||||
}}
|
||||
required
|
||||
onSave={handleSaveCommunity}
|
||||
/>
|
||||
</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
|
||||
|
|
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 { query } from '../graphql'
|
||||
import type { Query } from '../graphql/generated/schema'
|
||||
import { DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||
import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||
import TopicEditModal from '../modals/TopicEditModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
|
@ -53,6 +53,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
|||
show: false,
|
||||
topic: null
|
||||
})
|
||||
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
||||
show: false
|
||||
})
|
||||
|
||||
/**
|
||||
* Загружает список всех топиков
|
||||
|
@ -268,6 +271,40 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает новый топик
|
||||
*/
|
||||
const createTopic = async (newTopic: Topic) => {
|
||||
try {
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: CREATE_TOPIC_MUTATION,
|
||||
variables: { topic_input: newTopic }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
if (result.data.create_topic.error) {
|
||||
throw new Error(result.data.create_topic.error)
|
||||
}
|
||||
|
||||
props.onSuccess('Топик успешно создан')
|
||||
setCreateModal({ show: false })
|
||||
await loadTopics() // Перезагружаем список
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка создания топика: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет топик
|
||||
*/
|
||||
|
@ -305,7 +342,6 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
|||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<h2>Управление топиками</h2>
|
||||
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
||||
<label style={{ 'font-size': '14px', color: '#666' }}>Сортировка:</label>
|
||||
|
@ -339,6 +375,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
|||
<Button onClick={loadTopics} disabled={loading()}>
|
||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
|
||||
Создать тему
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -369,6 +408,14 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
|||
</table>
|
||||
</Show>
|
||||
|
||||
{/* Модальное окно создания */}
|
||||
<TopicEditModal
|
||||
isOpen={createModal().show}
|
||||
topic={null}
|
||||
onClose={() => setCreateModal({ show: false })}
|
||||
onSave={createTopic}
|
||||
/>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
<TopicEditModal
|
||||
isOpen={editModal().show}
|
||||
|
|
|
@ -9,6 +9,7 @@ from sqlalchemy.sql import func, select
|
|||
|
||||
from auth.decorators import admin_auth_required
|
||||
from auth.orm import Author, AuthorRole, Role
|
||||
from orm.invite import Invite, InviteStatus
|
||||
from orm.shout import Shout
|
||||
from services.db import local_session
|
||||
from services.env import EnvManager, EnvVariable
|
||||
|
@ -626,3 +627,307 @@ async def admin_restore_shout(_: None, info: GraphQLResolveInfo, shout_id: int)
|
|||
error_msg = f"Ошибка при восстановлении публикации: {e!s}"
|
||||
logger.error(error_msg)
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
|
||||
# === CRUD для приглашений ===
|
||||
|
||||
|
||||
@query.field("adminGetInvites")
|
||||
@admin_auth_required
|
||||
async def admin_get_invites(
|
||||
_: None, _info: GraphQLResolveInfo, limit: int = 10, offset: int = 0, search: str = "", status: str = "all"
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Получает список приглашений для админ-панели с поддержкой пагинации и поиска
|
||||
|
||||
Args:
|
||||
_info: Контекст GraphQL запроса
|
||||
limit: Максимальное количество записей для получения
|
||||
offset: Смещение в списке результатов
|
||||
search: Строка поиска (по email приглашающего/приглашаемого, названию публикации или ID)
|
||||
status: Фильтр по статусу ("all", "pending", "accepted", "rejected")
|
||||
|
||||
Returns:
|
||||
Пагинированный список приглашений
|
||||
"""
|
||||
try:
|
||||
# Нормализуем параметры
|
||||
limit = max(1, min(100, limit or 10))
|
||||
offset = max(0, offset or 0)
|
||||
|
||||
with local_session() as session:
|
||||
# Базовый запрос с загрузкой связанных объектов
|
||||
query = session.query(Invite).options(
|
||||
joinedload(Invite.inviter),
|
||||
joinedload(Invite.author),
|
||||
joinedload(Invite.shout).joinedload(Shout.created_by_author),
|
||||
)
|
||||
|
||||
# Фильтр по статусу
|
||||
if status and status != "all":
|
||||
status_enum = InviteStatus[status.upper()]
|
||||
query = query.filter(Invite.status == status_enum.value)
|
||||
|
||||
# Применяем фильтр поиска, если указан
|
||||
if search and search.strip():
|
||||
search_term = f"%{search.strip().lower()}%"
|
||||
query = (
|
||||
query.join(Invite.inviter.of_type(Author), aliased=True)
|
||||
.join(Invite.author.of_type(Author), aliased=True)
|
||||
.join(Invite.shout)
|
||||
.filter(
|
||||
or_(
|
||||
# Поиск по email приглашающего
|
||||
Invite.inviter.has(Author.email.ilike(search_term)),
|
||||
# Поиск по имени приглашающего
|
||||
Invite.inviter.has(Author.name.ilike(search_term)),
|
||||
# Поиск по email приглашаемого
|
||||
Invite.author.has(Author.email.ilike(search_term)),
|
||||
# Поиск по имени приглашаемого
|
||||
Invite.author.has(Author.name.ilike(search_term)),
|
||||
# Поиск по названию публикации
|
||||
Invite.shout.has(Shout.title.ilike(search_term)),
|
||||
# Поиск по ID приглашающего
|
||||
cast(Invite.inviter_id, String).ilike(search_term),
|
||||
# Поиск по ID приглашаемого
|
||||
cast(Invite.author_id, String).ilike(search_term),
|
||||
# Поиск по ID публикации
|
||||
cast(Invite.shout_id, String).ilike(search_term),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Получаем общее количество записей
|
||||
total_count = query.count()
|
||||
|
||||
# Вычисляем информацию о пагинации
|
||||
per_page = limit
|
||||
total_pages = ceil(total_count / per_page)
|
||||
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
||||
|
||||
# Применяем пагинацию и сортировку (по ID приглашающего, затем автора, затем публикации)
|
||||
invites = (
|
||||
query.order_by(Invite.inviter_id, Invite.author_id, Invite.shout_id).offset(offset).limit(limit).all()
|
||||
)
|
||||
|
||||
# Преобразуем в формат для API
|
||||
return {
|
||||
"invites": [
|
||||
{
|
||||
"inviter_id": invite.inviter_id,
|
||||
"author_id": invite.author_id,
|
||||
"shout_id": invite.shout_id,
|
||||
"status": invite.status,
|
||||
"inviter": {
|
||||
"id": invite.inviter.id,
|
||||
"name": invite.inviter.name or "Без имени",
|
||||
"email": invite.inviter.email,
|
||||
"slug": invite.inviter.slug,
|
||||
},
|
||||
"author": {
|
||||
"id": invite.author.id,
|
||||
"name": invite.author.name or "Без имени",
|
||||
"email": invite.author.email,
|
||||
"slug": invite.author.slug,
|
||||
},
|
||||
"shout": {
|
||||
"id": invite.shout.id,
|
||||
"title": invite.shout.title,
|
||||
"slug": invite.shout.slug,
|
||||
"created_by": {
|
||||
"id": invite.shout.created_by_author.id,
|
||||
"name": invite.shout.created_by_author.name or "Без имени",
|
||||
"email": invite.shout.created_by_author.email,
|
||||
"slug": invite.shout.created_by_author.slug,
|
||||
},
|
||||
},
|
||||
"created_at": None, # У приглашений нет created_at поля в текущей модели
|
||||
}
|
||||
for invite in invites
|
||||
],
|
||||
"total": total_count,
|
||||
"page": current_page,
|
||||
"perPage": per_page,
|
||||
"totalPages": total_pages,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"Ошибка при получении списка приглашений: {e!s}")
|
||||
logger.error(traceback.format_exc())
|
||||
msg = f"Не удалось получить список приглашений: {e!s}"
|
||||
raise GraphQLError(msg) from e
|
||||
|
||||
|
||||
@mutation.field("adminCreateInvite")
|
||||
@admin_auth_required
|
||||
async def admin_create_invite(_: None, _info: GraphQLResolveInfo, invite: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Создает новое приглашение
|
||||
|
||||
Args:
|
||||
_info: Контекст GraphQL запроса
|
||||
invite: Данные приглашения
|
||||
|
||||
Returns:
|
||||
Результат операции
|
||||
"""
|
||||
try:
|
||||
inviter_id = invite["inviter_id"]
|
||||
author_id = invite["author_id"]
|
||||
shout_id = invite["shout_id"]
|
||||
status = invite["status"]
|
||||
|
||||
with local_session() as session:
|
||||
# Проверяем существование всех связанных объектов
|
||||
inviter = session.query(Author).filter(Author.id == inviter_id).first()
|
||||
if not inviter:
|
||||
return {"success": False, "error": f"Приглашающий автор с ID {inviter_id} не найден"}
|
||||
|
||||
author = session.query(Author).filter(Author.id == author_id).first()
|
||||
if not author:
|
||||
return {"success": False, "error": f"Приглашаемый автор с ID {author_id} не найден"}
|
||||
|
||||
shout = session.query(Shout).filter(Shout.id == shout_id).first()
|
||||
if not shout:
|
||||
return {"success": False, "error": f"Публикация с ID {shout_id} не найдена"}
|
||||
|
||||
# Проверяем, не существует ли уже такое приглашение
|
||||
existing_invite = (
|
||||
session.query(Invite)
|
||||
.filter(
|
||||
Invite.inviter_id == inviter_id,
|
||||
Invite.author_id == author_id,
|
||||
Invite.shout_id == shout_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_invite:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Приглашение от {inviter.name} для {author.name} на публикацию '{shout.title}' уже существует",
|
||||
}
|
||||
|
||||
# Создаем новое приглашение
|
||||
new_invite = Invite(
|
||||
inviter_id=inviter_id,
|
||||
author_id=author_id,
|
||||
shout_id=shout_id,
|
||||
status=status,
|
||||
)
|
||||
|
||||
session.add(new_invite)
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Создано приглашение: {inviter.name} приглашает {author.name} к публикации '{shout.title}'")
|
||||
|
||||
return {"success": True, "error": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании приглашения: {e!s}")
|
||||
msg = f"Не удалось создать приглашение: {e!s}"
|
||||
raise GraphQLError(msg) from e
|
||||
|
||||
|
||||
@mutation.field("adminUpdateInvite")
|
||||
@admin_auth_required
|
||||
async def admin_update_invite(_: None, _info: GraphQLResolveInfo, invite: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Обновляет существующее приглашение
|
||||
|
||||
Args:
|
||||
_info: Контекст GraphQL запроса
|
||||
invite: Данные приглашения для обновления
|
||||
|
||||
Returns:
|
||||
Результат операции
|
||||
"""
|
||||
try:
|
||||
inviter_id = invite["inviter_id"]
|
||||
author_id = invite["author_id"]
|
||||
shout_id = invite["shout_id"]
|
||||
new_status = invite["status"]
|
||||
|
||||
with local_session() as session:
|
||||
# Находим существующее приглашение
|
||||
existing_invite = (
|
||||
session.query(Invite)
|
||||
.filter(
|
||||
Invite.inviter_id == inviter_id,
|
||||
Invite.author_id == author_id,
|
||||
Invite.shout_id == shout_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing_invite:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Приглашение с ID {inviter_id}-{author_id}-{shout_id} не найдено",
|
||||
}
|
||||
|
||||
# Обновляем статус
|
||||
old_status = existing_invite.status
|
||||
existing_invite.status = new_status
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Обновлён статус приглашения {inviter_id}-{author_id}-{shout_id}: {old_status} → {new_status}")
|
||||
|
||||
return {"success": True, "error": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении приглашения: {e!s}")
|
||||
msg = f"Не удалось обновить приглашение: {e!s}"
|
||||
raise GraphQLError(msg) from e
|
||||
|
||||
|
||||
@mutation.field("adminDeleteInvite")
|
||||
@admin_auth_required
|
||||
async def admin_delete_invite(
|
||||
_: None, _info: GraphQLResolveInfo, inviter_id: int, author_id: int, shout_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Удаляет приглашение
|
||||
|
||||
Args:
|
||||
_info: Контекст GraphQL запроса
|
||||
inviter_id: ID приглашающего
|
||||
author_id: ID приглашаемого
|
||||
shout_id: ID публикации
|
||||
|
||||
Returns:
|
||||
Результат операции
|
||||
"""
|
||||
try:
|
||||
with local_session() as session:
|
||||
# Находим приглашение для удаления
|
||||
invite = (
|
||||
session.query(Invite)
|
||||
.filter(
|
||||
Invite.inviter_id == inviter_id,
|
||||
Invite.author_id == author_id,
|
||||
Invite.shout_id == shout_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not invite:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Приглашение с ID {inviter_id}-{author_id}-{shout_id} не найдено",
|
||||
}
|
||||
|
||||
# Удаляем приглашение
|
||||
session.delete(invite)
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Удалено приглашение {inviter_id}-{author_id}-{shout_id}")
|
||||
|
||||
return {"success": True, "error": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении приглашения: {e!s}")
|
||||
msg = f"Не удалось удалить приглашение: {e!s}"
|
||||
raise GraphQLError(msg) from e
|
||||
|
|
|
@ -113,6 +113,34 @@ input AdminShoutUpdateInput {
|
|||
deleted_at: Int
|
||||
}
|
||||
|
||||
# Тип для отображения приглашения в админ-панели
|
||||
type AdminInviteInfo {
|
||||
inviter_id: Int!
|
||||
author_id: Int!
|
||||
shout_id: Int!
|
||||
status: InviteStatus!
|
||||
inviter: Author!
|
||||
author: Author!
|
||||
shout: AdminShoutInfo!
|
||||
created_at: Int
|
||||
}
|
||||
|
||||
# Тип для пагинированного ответа приглашений
|
||||
type AdminInviteListResponse {
|
||||
invites: [AdminInviteInfo!]!
|
||||
total: Int!
|
||||
page: Int!
|
||||
perPage: Int!
|
||||
totalPages: Int!
|
||||
}
|
||||
|
||||
input AdminInviteUpdateInput {
|
||||
inviter_id: Int!
|
||||
author_id: Int!
|
||||
shout_id: Int!
|
||||
status: InviteStatus!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
getEnvVariables: [EnvSection!]!
|
||||
# Запросы для управления пользователями
|
||||
|
@ -120,6 +148,8 @@ extend type Query {
|
|||
adminGetRoles: [Role!]!
|
||||
# Запросы для управления публикациями
|
||||
adminGetShouts(limit: Int, offset: Int, search: String, status: String): AdminShoutListResponse!
|
||||
# Запросы для управления приглашениями
|
||||
adminGetInvites(limit: Int, offset: Int, search: String, status: String): AdminInviteListResponse!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
@ -132,4 +162,8 @@ extend type Mutation {
|
|||
adminUpdateShout(shout: AdminShoutUpdateInput!): OperationResult!
|
||||
adminDeleteShout(id: Int!): OperationResult!
|
||||
adminRestoreShout(id: Int!): OperationResult!
|
||||
# Мутации для управления приглашениями
|
||||
adminCreateInvite(invite: AdminInviteUpdateInput!): OperationResult!
|
||||
adminUpdateInvite(invite: AdminInviteUpdateInput!): OperationResult!
|
||||
adminDeleteInvite(inviter_id: Int!, author_id: Int!, shout_id: Int!): OperationResult!
|
||||
}
|
||||
|
|
|
@ -170,6 +170,7 @@ type Collection {
|
|||
slug: String!
|
||||
title: String!
|
||||
desc: String
|
||||
pic: String
|
||||
amount: Int
|
||||
published_at: Int
|
||||
created_at: Int!
|
||||
|
|
Loading…
Reference in New Issue
Block a user