This commit is contained in:
@@ -1,90 +1,151 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js'
|
||||
import { createEffect, createSignal, Show } from 'solid-js'
|
||||
import { useData } from '../context/data'
|
||||
import type { Role } from '../graphql/generated/schema'
|
||||
import {
|
||||
GET_COMMUNITY_ROLE_SETTINGS_QUERY,
|
||||
GET_COMMUNITY_ROLES_QUERY,
|
||||
UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION
|
||||
} from '../graphql/queries'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
import RoleManager from '../ui/RoleManager'
|
||||
|
||||
interface Community {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
slug: string
|
||||
desc?: string
|
||||
pic: string
|
||||
created_at: number
|
||||
created_by: {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
stat: {
|
||||
shouts: number
|
||||
followers: number
|
||||
authors: number
|
||||
}
|
||||
pic?: string
|
||||
}
|
||||
|
||||
interface CommunityEditModalProps {
|
||||
isOpen: boolean
|
||||
community: Community | null // null для создания нового
|
||||
community: Community | null
|
||||
onClose: () => void
|
||||
onSave: (community: Partial<Community>) => void
|
||||
onSave: (communityData: Partial<Community>) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное окно для создания и редактирования сообществ
|
||||
*/
|
||||
const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
|
||||
const [formData, setFormData] = createSignal({
|
||||
slug: '',
|
||||
name: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
})
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
interface RoleSettings {
|
||||
default_roles: string[]
|
||||
available_roles: string[]
|
||||
}
|
||||
|
||||
// Синхронизация с props.community
|
||||
interface CustomRole {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const STANDARD_ROLES = [
|
||||
{ id: 'reader', name: 'Читатель', description: 'Может читать и комментировать', icon: '👁️' },
|
||||
{ id: 'author', name: 'Автор', description: 'Может создавать публикации', icon: '✍️' },
|
||||
{ id: 'artist', name: 'Художник', description: 'Может быть credited artist', icon: '🎨' },
|
||||
{ id: 'expert', name: 'Эксперт', description: 'Может добавлять доказательства', icon: '🧠' },
|
||||
{ id: 'editor', name: 'Редактор', description: 'Может модерировать контент', icon: '📝' },
|
||||
{ id: 'admin', name: 'Администратор', description: 'Полные права', icon: '👑' }
|
||||
]
|
||||
|
||||
const CommunityEditModal = (props: CommunityEditModalProps) => {
|
||||
const { queryGraphQL } = useData()
|
||||
const [formData, setFormData] = createSignal<Partial<Community>>({})
|
||||
const [roleSettings, setRoleSettings] = createSignal<RoleSettings>({
|
||||
default_roles: ['reader'],
|
||||
available_roles: ['reader', 'author', 'artist', 'expert', 'editor', 'admin']
|
||||
})
|
||||
const [customRoles, setCustomRoles] = createSignal<CustomRole[]>([])
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
const [activeTab, setActiveTab] = createSignal<'basic' | 'roles'>('basic')
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
// Инициализация формы при открытии
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
if (props.community) {
|
||||
// Редактирование существующего сообщества
|
||||
setFormData({
|
||||
slug: props.community.slug,
|
||||
name: props.community.name,
|
||||
name: props.community.name || '',
|
||||
slug: props.community.slug || '',
|
||||
desc: props.community.desc || '',
|
||||
pic: props.community.pic
|
||||
pic: props.community.pic || ''
|
||||
})
|
||||
void loadRoleSettings()
|
||||
} else {
|
||||
// Создание нового сообщества
|
||||
setFormData({
|
||||
slug: '',
|
||||
name: '',
|
||||
desc: '',
|
||||
pic: ''
|
||||
setFormData({ name: '', slug: '', desc: '', pic: '' })
|
||||
setRoleSettings({
|
||||
default_roles: ['reader'],
|
||||
available_roles: ['reader', 'author', 'artist', 'expert', 'editor', 'admin']
|
||||
})
|
||||
}
|
||||
setErrors({})
|
||||
setActiveTab('basic')
|
||||
setCustomRoles([])
|
||||
}
|
||||
})
|
||||
|
||||
const validateForm = () => {
|
||||
const loadRoleSettings = async () => {
|
||||
if (!props.community?.id) return
|
||||
|
||||
try {
|
||||
const data = await queryGraphQL(GET_COMMUNITY_ROLE_SETTINGS_QUERY, {
|
||||
community_id: props.community.id
|
||||
})
|
||||
|
||||
if (data?.adminGetCommunityRoleSettings && !data.adminGetCommunityRoleSettings.error) {
|
||||
setRoleSettings({
|
||||
default_roles: data.adminGetCommunityRoleSettings.default_roles,
|
||||
available_roles: data.adminGetCommunityRoleSettings.available_roles
|
||||
})
|
||||
}
|
||||
|
||||
// Загружаем все роли сообщества для получения произвольных
|
||||
const rolesData = await queryGraphQL(GET_COMMUNITY_ROLES_QUERY, {
|
||||
community: props.community.id
|
||||
})
|
||||
|
||||
if (rolesData?.adminGetRoles) {
|
||||
// Фильтруем только произвольные роли (не стандартные)
|
||||
const standardRoleIds = STANDARD_ROLES.map((r) => r.id)
|
||||
const customRolesList = rolesData.adminGetRoles
|
||||
.filter((role: Role) => !standardRoleIds.includes(role.id))
|
||||
.map((role: Role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
icon: '🔖' // Пока иконки не хранятся в БД
|
||||
}))
|
||||
|
||||
setCustomRoles(customRolesList)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки настроек ролей:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
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()) {
|
||||
if (!data.name?.trim()) {
|
||||
newErrors.name = 'Название обязательно'
|
||||
}
|
||||
|
||||
// Валидация URL картинки (если указан)
|
||||
if (data.pic.trim() && !/^https?:\/\/.+/.test(data.pic)) {
|
||||
newErrors.pic = 'Некорректный URL картинки'
|
||||
if (!data.slug?.trim()) {
|
||||
newErrors.slug = 'Слаг обязательный'
|
||||
} else if (!/^[a-z0-9-]+$/.test(data.slug)) {
|
||||
newErrors.slug = 'Слаг может содержать только латинские буквы, цифры и дефисы'
|
||||
}
|
||||
|
||||
// Валидация ролей
|
||||
const roleSet = roleSettings()
|
||||
if (roleSet.default_roles.length === 0) {
|
||||
newErrors.roles = 'Должна быть хотя бы одна дефолтная роль'
|
||||
}
|
||||
|
||||
const invalidDefaults = roleSet.default_roles.filter((role) => !roleSet.available_roles.includes(role))
|
||||
if (invalidDefaults.length > 0) {
|
||||
newErrors.roles = 'Дефолтные роли должны быть из списка доступных'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
@@ -93,17 +154,39 @@ const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
|
||||
|
||||
const updateField = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Очищаем ошибку для поля при изменении
|
||||
setErrors((prev) => ({ ...prev, [field]: '' }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
const communityData = { ...formData() }
|
||||
props.onSave(communityData)
|
||||
setLoading(true)
|
||||
try {
|
||||
// Сохраняем основные данные сообщества
|
||||
await props.onSave(formData())
|
||||
|
||||
// Если редактируем существующее сообщество, сохраняем настройки ролей
|
||||
if (props.community?.id) {
|
||||
const roleData = await queryGraphQL(UPDATE_COMMUNITY_ROLE_SETTINGS_MUTATION, {
|
||||
community_id: props.community.id,
|
||||
default_roles: roleSettings().default_roles,
|
||||
available_roles: roleSettings().available_roles
|
||||
})
|
||||
|
||||
if (!roleData?.adminUpdateCommunityRoleSettings?.success) {
|
||||
console.error(
|
||||
'Ошибка сохранения настроек ролей:',
|
||||
roleData?.adminUpdateCommunityRoleSettings?.error
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isCreating = () => props.community === null
|
||||
@@ -113,76 +196,149 @@ const CommunityEditModal: Component<CommunityEditModalProps> = (props) => {
|
||||
: `Редактирование сообщества: ${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 сообщества. Только латинские буквы, цифры, дефисы и подчеркивания.
|
||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="large">
|
||||
<div class={styles.content}>
|
||||
{/* Табы */}
|
||||
<div class={formStyles.tabs}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${formStyles.tab} ${activeTab() === 'basic' ? formStyles.active : ''}`}
|
||||
onClick={() => setActiveTab('basic')}
|
||||
>
|
||||
<span class={formStyles.tabIcon}>⚙️</span>
|
||||
Основные настройки
|
||||
</button>
|
||||
<Show when={!isCreating()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${formStyles.tab} ${activeTab() === 'roles' ? formStyles.active : ''}`}
|
||||
onClick={() => setActiveTab('roles')}
|
||||
>
|
||||
<span class={formStyles.tabIcon}>👥</span>
|
||||
Роли и права
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Контент табов */}
|
||||
<div class={formStyles.content}>
|
||||
<Show when={activeTab() === 'basic'}>
|
||||
<div class={formStyles.form}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🏷️</span>
|
||||
Название сообщества
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().name ? formStyles.error : ''}`}
|
||||
value={formData().name || ''}
|
||||
onInput={(e) => updateField('name', e.currentTarget.value)}
|
||||
placeholder="Введите название сообщества"
|
||||
/>
|
||||
<Show when={errors().name}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().name}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔗</span>
|
||||
Слаг
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
|
||||
value={formData().slug || ''}
|
||||
onInput={(e) => updateField('slug', e.currentTarget.value)}
|
||||
placeholder="community-slug"
|
||||
disabled={!isCreating()}
|
||||
/>
|
||||
<Show when={errors().slug}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().slug}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!isCreating()}>
|
||||
<span class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Слаг нельзя изменить после создания
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📝</span>
|
||||
Описание
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
class={formStyles.textarea}
|
||||
value={formData().desc || ''}
|
||||
onInput={(e) => updateField('desc', e.currentTarget.value)}
|
||||
placeholder="Описание сообщества"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🖼️</span>
|
||||
Изображение (URL)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
class={formStyles.input}
|
||||
value={formData().pic || ''}
|
||||
onInput={(e) => updateField('pic', e.currentTarget.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{errors().slug && <div class={formStyles.fieldError}>{errors().slug}</div>}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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
|
||||
<Show when={activeTab() === 'roles' && !isCreating()}>
|
||||
<RoleManager
|
||||
communityId={props.community?.id}
|
||||
roleSettings={roleSettings()}
|
||||
onRoleSettingsChange={setRoleSettings}
|
||||
customRoles={customRoles()}
|
||||
onCustomRolesChange={setCustomRoles}
|
||||
/>
|
||||
{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>
|
||||
<Show when={errors().roles}>
|
||||
<span class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().roles}
|
||||
</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</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 class={styles.footer}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading()}>
|
||||
<Show when={loading()}>
|
||||
<span class={formStyles.spinner} />
|
||||
</Show>
|
||||
{loading() ? 'Сохранение...' : isCreating() ? 'Создать' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
Reference in New Issue
Block a user