This commit is contained in:
@@ -109,68 +109,99 @@ const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
|
||||
<div class={styles['modal-content']}>
|
||||
<div class={styles.modalContent}>
|
||||
<div class={formStyles.form}>
|
||||
<div class={formStyles['form-group']}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<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>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📝</span>
|
||||
Название
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class={`${formStyles.input} ${errors().title ? formStyles.error : ''}`}
|
||||
value={formData().title}
|
||||
onInput={(e) => updateField('title', e.target.value)}
|
||||
class={`${formStyles.input} ${errors().title ? formStyles.inputError : ''}`}
|
||||
placeholder="Название коллекции"
|
||||
placeholder="Введите название коллекции"
|
||||
required
|
||||
/>
|
||||
{errors().title && <div class={formStyles.fieldError}>{errors().title}</div>}
|
||||
{errors().title && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{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>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔗</span>
|
||||
Slug
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</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"
|
||||
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
|
||||
value={formData().slug}
|
||||
onInput={(e) => updateField('slug', e.target.value)}
|
||||
placeholder="collection-slug"
|
||||
required
|
||||
/>
|
||||
{errors().pic && <div class={formStyles.fieldError}>{errors().pic}</div>}
|
||||
{errors().slug && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().slug}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<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.target.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} ${errors().pic ? formStyles.error : ''}`}
|
||||
value={formData().pic}
|
||||
onInput={(e) => updateField('pic', e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
{errors().pic && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().pic}
|
||||
</div>
|
||||
)}
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Необязательно. URL изображения для обложки коллекции.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.modalActions}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
|
@@ -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>
|
||||
|
182
panel/modals/CommunityRolesModal.tsx
Normal file
182
panel/modals/CommunityRolesModal.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Component, createEffect, createSignal, For, Show } from 'solid-js'
|
||||
import { useData } from '../context/data'
|
||||
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 Community {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface CommunityRolesModalProps {
|
||||
isOpen: boolean
|
||||
author: Author | null
|
||||
community: Community | null
|
||||
onClose: () => void
|
||||
onSave: (authorId: number, communityId: number, roles: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
const CommunityRolesModal: Component<CommunityRolesModalProps> = (props) => {
|
||||
const { queryGraphQL } = useData()
|
||||
const [roles, setRoles] = createSignal<Role[]>([])
|
||||
const [userRoles, setUserRoles] = createSignal<string[]>([])
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
// Загружаем доступные роли при открытии модала
|
||||
createEffect(() => {
|
||||
if (props.isOpen && props.community) {
|
||||
void loadRolesData()
|
||||
}
|
||||
})
|
||||
|
||||
const loadRolesData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Получаем доступные роли
|
||||
const rolesData = await queryGraphQL(
|
||||
`
|
||||
query GetRoles($community: Int) {
|
||||
adminGetRoles(community: $community) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ community: props.community?.id }
|
||||
)
|
||||
|
||||
if (rolesData?.adminGetRoles) {
|
||||
setRoles(rolesData.adminGetRoles)
|
||||
}
|
||||
|
||||
// Получаем текущие роли пользователя
|
||||
if (props.author) {
|
||||
const membersData = await queryGraphQL(
|
||||
`
|
||||
query GetCommunityMembers($community_id: Int!) {
|
||||
adminGetCommunityMembers(community_id: $community_id, limit: 1000) {
|
||||
members {
|
||||
id
|
||||
roles
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ community_id: props.community?.id }
|
||||
)
|
||||
|
||||
const members = membersData?.adminGetCommunityMembers?.members || []
|
||||
const currentUser = members.find((m: { id: number }) => m.id === props.author?.id)
|
||||
setUserRoles(currentUser?.roles || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки ролей:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = (roleId: string) => {
|
||||
const currentRoles = userRoles()
|
||||
if (currentRoles.includes(roleId)) {
|
||||
setUserRoles(currentRoles.filter((r) => r !== roleId))
|
||||
} else {
|
||||
setUserRoles([...currentRoles, roleId])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!props.author || !props.community) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await props.onSave(props.author.id, props.community.id, userRoles())
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения ролей:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
title={`Роли пользователя: ${props.author?.name || ''}`}
|
||||
>
|
||||
<div class={styles.content}>
|
||||
<Show when={props.community && props.author}>
|
||||
<div class={formStyles.field}>
|
||||
<label class={formStyles.label}>
|
||||
Сообщество: <strong>{props.community?.name}</strong>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.field}>
|
||||
<label class={formStyles.label}>
|
||||
Пользователь: <strong>{props.author?.name}</strong> ({props.author?.email})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={formStyles.field}>
|
||||
<label class={formStyles.label}>Роли:</label>
|
||||
<Show when={!loading()} fallback={<div>Загрузка ролей...</div>}>
|
||||
<div class={formStyles.checkboxGroup}>
|
||||
<For each={roles()}>
|
||||
{(role) => (
|
||||
<div class={formStyles.checkboxItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`role-${role.id}`}
|
||||
checked={userRoles().includes(role.id)}
|
||||
onChange={() => handleRoleToggle(role.id)}
|
||||
class={formStyles.checkbox}
|
||||
/>
|
||||
<label for={`role-${role.id}`} class={formStyles.checkboxLabel}>
|
||||
<div>
|
||||
<strong>{role.name}</strong>
|
||||
<Show when={role.description}>
|
||||
<div class={formStyles.description}>{role.description}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.actions}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading()}>
|
||||
{loading() ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommunityRolesModal
|
@@ -89,37 +89,46 @@ const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
|
||||
onClose={props.onClose}
|
||||
size="large"
|
||||
>
|
||||
<div class={formStyles['modal-wide']}>
|
||||
<div class={formStyles.modalWide}>
|
||||
<form class={formStyles.form} onSubmit={(e) => e.preventDefault()}>
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles['form-label']}>Ключ:</label>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔑</span>
|
||||
Ключ
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.variable.key}
|
||||
disabled
|
||||
class={formStyles['form-input-disabled']}
|
||||
class={`${formStyles.input} ${formStyles.disabled}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles['form-label']}>
|
||||
Значение:
|
||||
<span class={formStyles['form-label-info']}>
|
||||
{props.variable.type} {props.variable.isSecret && '(секретное)'}
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>💾</span>
|
||||
Значение
|
||||
<span class={formStyles.labelInfo}>
|
||||
({props.variable.type}
|
||||
{props.variable.isSecret && ', секретное'})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Show when={needsTextarea()}>
|
||||
<div class={formStyles['textarea-container']}>
|
||||
<div class={formStyles.textareaContainer}>
|
||||
<textarea
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
class={formStyles['form-textarea']}
|
||||
class={formStyles.textarea}
|
||||
rows={Math.min(Math.max(value().split('\n').length + 2, 4), 15)}
|
||||
placeholder="Введите значение переменной..."
|
||||
/>
|
||||
<Show when={props.variable.type === 'json'}>
|
||||
<div class={formStyles['textarea-actions']}>
|
||||
<div class={formStyles.textareaActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -146,32 +155,37 @@ const EnvVariableModal: Component<EnvVariableModalProps> = (props) => {
|
||||
type={props.variable.isSecret ? 'password' : 'text'}
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
class={formStyles['form-input']}
|
||||
class={formStyles.input}
|
||||
placeholder="Введите значение переменной..."
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={showFormatted() && (props.variable.type === 'json' || value().startsWith('{'))}>
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles['form-label']}>Превью (форматированное):</label>
|
||||
<div class={formStyles['code-preview-container']}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>👁️</span>
|
||||
Превью (форматированное)
|
||||
</span>
|
||||
</label>
|
||||
<div class={formStyles.codePreview}>
|
||||
<TextPreview content={formattedValue()} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.variable.description}>
|
||||
<div class={formStyles['form-help']}>
|
||||
<div class={formStyles.formHelp}>
|
||||
<strong>Описание:</strong> {props.variable.description}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class={formStyles['form-error']}>{error()}</div>
|
||||
<div class={formStyles.formError}>{error()}</div>
|
||||
</Show>
|
||||
|
||||
<div class={formStyles['form-actions']}>
|
||||
<div class={formStyles.formActions}>
|
||||
<Button variant="secondary" onClick={props.onClose} disabled={saving()}>
|
||||
Отменить
|
||||
</Button>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js'
|
||||
import { Component, createEffect, createSignal, Show } from 'solid-js'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
@@ -123,93 +123,144 @@ const InviteEditModal: Component<InviteEditModalProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.isOpen} onClose={props.onClose} title={modalTitle()} size="medium">
|
||||
<div class={styles['modal-content']}>
|
||||
<div class={styles.modalContent}>
|
||||
<div class={formStyles.form}>
|
||||
<div class={formStyles['form-group']}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
ID приглашающего <span style={{ color: 'red' }}>*</span>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>👤</span>
|
||||
ID приглашающего
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData().inviter_id}
|
||||
onInput={(e) => updateField('inviter_id', Number.parseInt(e.target.value) || 0)}
|
||||
class={`${formStyles.input} ${errors().inviter_id ? formStyles.inputError : ''}`}
|
||||
class={`${formStyles.input} ${errors().inviter_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
|
||||
placeholder="1"
|
||||
required
|
||||
disabled={!isCreating()} // При редактировании ID нельзя менять
|
||||
/>
|
||||
<div class={formStyles.fieldHint}>ID автора, который отправляет приглашение</div>
|
||||
{errors().inviter_id && <div class={formStyles.fieldError}>{errors().inviter_id}</div>}
|
||||
{errors().inviter_id && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().inviter_id}
|
||||
</div>
|
||||
)}
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
ID автора, который отправляет приглашение
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
ID приглашаемого <span style={{ color: 'red' }}>*</span>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>👥</span>
|
||||
ID приглашаемого
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData().author_id}
|
||||
onInput={(e) => updateField('author_id', Number.parseInt(e.target.value) || 0)}
|
||||
class={`${formStyles.input} ${errors().author_id ? formStyles.inputError : ''}`}
|
||||
class={`${formStyles.input} ${errors().author_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
|
||||
placeholder="2"
|
||||
required
|
||||
disabled={!isCreating()} // При редактировании ID нельзя менять
|
||||
/>
|
||||
<div class={formStyles.fieldHint}>ID автора, которого приглашают к сотрудничеству</div>
|
||||
{errors().author_id && <div class={formStyles.fieldError}>{errors().author_id}</div>}
|
||||
<Show when={errors().author_id}>
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().author_id}
|
||||
</div>
|
||||
</Show>
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
ID автора, которого приглашают к сотрудничеству
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
ID публикации <span style={{ color: 'red' }}>*</span>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📄</span>
|
||||
ID публикации
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData().shout_id}
|
||||
onInput={(e) => updateField('shout_id', Number.parseInt(e.target.value) || 0)}
|
||||
class={`${formStyles.input} ${errors().shout_id ? formStyles.inputError : ''}`}
|
||||
class={`${formStyles.input} ${errors().shout_id ? formStyles.error : ''} ${!isCreating() ? formStyles.disabled : ''}`}
|
||||
placeholder="123"
|
||||
required
|
||||
disabled={!isCreating()} // При редактировании ID нельзя менять
|
||||
/>
|
||||
<div class={formStyles.fieldHint}>ID публикации, к которой приглашают на сотрудничество</div>
|
||||
{errors().shout_id && <div class={formStyles.fieldError}>{errors().shout_id}</div>}
|
||||
<Show when={errors().shout_id}>
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().shout_id}
|
||||
</div>
|
||||
</Show>
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
ID публикации, к которой приглашают на сотрудничество
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
Статус <span style={{ color: 'red' }}>*</span>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📋</span>
|
||||
Статус
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData().status}
|
||||
onChange={(e) => updateField('status', e.target.value)}
|
||||
class={formStyles.input}
|
||||
class={formStyles.select}
|
||||
required
|
||||
>
|
||||
<option value="PENDING">Ожидает ответа</option>
|
||||
<option value="ACCEPTED">Принято</option>
|
||||
<option value="REJECTED">Отклонено</option>
|
||||
</select>
|
||||
<div class={formStyles.fieldHint}>Текущий статус приглашения</div>
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Текущий статус приглашения
|
||||
</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})
|
||||
<Show when={!isCreating() && props.invite}>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>ℹ️</span>
|
||||
Информация о приглашении
|
||||
</span>
|
||||
</label>
|
||||
<div class={formStyles.hint} style={{ 'margin-bottom': '8px' }}>
|
||||
<span class={formStyles.hintIcon}>👤</span>
|
||||
<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 class={formStyles.hint} style={{ 'margin-bottom': '8px' }}>
|
||||
<span class={formStyles.hintIcon}>👥</span>
|
||||
<strong>Приглашаемый:</strong> {props.invite?.author.name} ({props.invite?.author.email})
|
||||
</div>
|
||||
<div class={formStyles.fieldHint}>
|
||||
<strong>Публикация:</strong> {props.invite.shout.title}
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>📄</span>
|
||||
<strong>Публикация:</strong> {props.invite?.shout.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<div class={styles.modalActions}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Component, createEffect, createSignal, For } from 'solid-js'
|
||||
import type { AdminUserInfo } from '../graphql/generated/schema'
|
||||
import styles from '../styles/Form.module.css'
|
||||
import formStyles from '../styles/Form.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
@@ -17,87 +17,146 @@ export interface UserEditModalProps {
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
// Доступные роли в системе (без роли Администратор - она определяется автоматически)
|
||||
const AVAILABLE_ROLES = [
|
||||
{ id: 'admin', name: 'Администратор', description: 'Полный доступ к системе' },
|
||||
{ id: 'editor', name: 'Редактор', description: 'Редактирование публикаций и управление сообществом' },
|
||||
{
|
||||
id: 'expert',
|
||||
name: 'Эксперт',
|
||||
description: 'Добавление доказательств и опровержений, управление темами'
|
||||
id: 'Редактор',
|
||||
name: 'Редактор',
|
||||
description: 'Редактирование публикаций и управление сообществом',
|
||||
emoji: '✒️'
|
||||
},
|
||||
{ id: 'author', name: 'Автор', description: 'Создание и редактирование своих публикаций' },
|
||||
{ id: 'reader', name: 'Читатель', description: 'Чтение и комментирование' }
|
||||
{
|
||||
id: 'Эксперт',
|
||||
name: 'Эксперт',
|
||||
description: 'Добавление доказательств и опровержений, управление темами',
|
||||
emoji: '🔬'
|
||||
},
|
||||
{
|
||||
id: 'Автор',
|
||||
name: 'Автор',
|
||||
description: 'Создание и редактирование своих публикаций',
|
||||
emoji: '📝'
|
||||
},
|
||||
{
|
||||
id: 'Читатель',
|
||||
name: 'Читатель',
|
||||
description: 'Чтение и комментирование',
|
||||
emoji: '📖'
|
||||
}
|
||||
]
|
||||
|
||||
const UserEditModal: Component<UserEditModalProps> = (props) => {
|
||||
const [formData, setFormData] = createSignal({
|
||||
id: props.user.id,
|
||||
email: props.user.email || '',
|
||||
name: props.user.name || '',
|
||||
slug: props.user.slug || '',
|
||||
roles: props.user.roles || []
|
||||
roles: props.user.roles?.filter((role) => role !== 'Администратор') || [] // Исключаем админскую роль из ручного управления
|
||||
})
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
|
||||
// Сброс формы при открытии модалки
|
||||
const [errors, setErrors] = createSignal<Record<string, string>>({})
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
// Проверяем, является ли пользователь администратором по ролям, которые приходят с сервера
|
||||
const isAdmin = () => {
|
||||
return (props.user.roles || []).includes('Администратор')
|
||||
}
|
||||
|
||||
// Получаем информацию о роли по ID
|
||||
const getRoleInfo = (roleId: string) => {
|
||||
return AVAILABLE_ROLES.find((role) => role.id === roleId) || { name: roleId, emoji: '🎭' }
|
||||
}
|
||||
|
||||
// Формируем строку с ролями и эмоджи
|
||||
const getRolesDisplay = () => {
|
||||
const roles = formData().roles
|
||||
if (roles.length === 0) {
|
||||
return isAdmin() ? '🪄 Администратор' : 'Роли не назначены'
|
||||
}
|
||||
|
||||
const roleTexts = roles.map((roleId) => {
|
||||
const role = getRoleInfo(roleId)
|
||||
return `${role.emoji} ${role.name}`
|
||||
})
|
||||
|
||||
if (isAdmin()) {
|
||||
return `🪄 Администратор, ${roleTexts.join(', ')}`
|
||||
}
|
||||
|
||||
return roleTexts.join(', ')
|
||||
}
|
||||
|
||||
// Обновляем форму при изменении пользователя
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
if (props.user) {
|
||||
setFormData({
|
||||
id: props.user.id,
|
||||
email: props.user.email || '',
|
||||
name: props.user.name || '',
|
||||
slug: props.user.slug || '',
|
||||
roles: props.user.roles || []
|
||||
roles: props.user.roles?.filter((role) => role !== 'Администратор') || [] // Исключаем админскую роль
|
||||
})
|
||||
setErrors({})
|
||||
}
|
||||
})
|
||||
|
||||
const validateForm = () => {
|
||||
const updateField = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Очищаем ошибку при изменении поля
|
||||
if (errors()[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = (roleId: string) => {
|
||||
setFormData((prev) => {
|
||||
const currentRoles = prev.roles
|
||||
const newRoles = currentRoles.includes(roleId)
|
||||
? currentRoles.filter((r) => r !== roleId)
|
||||
: [...currentRoles, roleId]
|
||||
return { ...prev, roles: newRoles }
|
||||
})
|
||||
|
||||
// Очищаем ошибку ролей при изменении
|
||||
if (errors().roles) {
|
||||
setErrors((prev) => ({ ...prev, roles: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
const data = formData()
|
||||
|
||||
// Валидация email
|
||||
// Email
|
||||
if (!data.email.trim()) {
|
||||
newErrors.email = 'Email обязателен'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
newErrors.email = 'Некорректный формат email'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email.trim())) {
|
||||
newErrors.email = 'Неверный формат email'
|
||||
}
|
||||
|
||||
// Валидация имени
|
||||
// Имя
|
||||
if (!data.name.trim()) {
|
||||
newErrors.name = 'Имя обязательно'
|
||||
} else if (data.name.trim().length < 2) {
|
||||
newErrors.name = 'Имя должно содержать минимум 2 символа'
|
||||
}
|
||||
|
||||
// Валидация slug
|
||||
// Slug
|
||||
if (!data.slug.trim()) {
|
||||
newErrors.slug = 'Slug обязателен'
|
||||
} else if (!/^[a-z0-9-_]+$/.test(data.slug)) {
|
||||
} else if (!/^[a-z0-9_-]+$/.test(data.slug.trim())) {
|
||||
newErrors.slug = 'Slug может содержать только латинские буквы, цифры, дефисы и подчеркивания'
|
||||
}
|
||||
|
||||
// Валидация ролей
|
||||
if (data.roles.length === 0) {
|
||||
newErrors.roles = 'Выберите хотя бы одну роль'
|
||||
// Роли (админы освобождаются от этого требования)
|
||||
if (!isAdmin() && data.roles.length === 0) {
|
||||
newErrors.roles = 'Выберите хотя бы одну роль (или назначьте админский email)'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const updateField = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Очищаем ошибку для поля при изменении
|
||||
setErrors((prev) => ({ ...prev, [field]: '' }))
|
||||
}
|
||||
|
||||
const handleRoleToggle = (roleId: string) => {
|
||||
const current = formData().roles
|
||||
const newRoles = current.includes(roleId) ? current.filter((r) => r !== roleId) : [...current, roleId]
|
||||
|
||||
setFormData((prev) => ({ ...prev, roles: newRoles }))
|
||||
setErrors((prev) => ({ ...prev, roles: '' }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
@@ -105,144 +164,184 @@ const UserEditModal: Component<UserEditModalProps> = (props) => {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await props.onSave({
|
||||
id: props.user.id,
|
||||
email: formData().email,
|
||||
name: formData().name,
|
||||
slug: formData().slug,
|
||||
roles: formData().roles
|
||||
})
|
||||
// Отправляем только обычные роли, админская роль определяется на сервере по email
|
||||
await props.onSave(formData())
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
console.error('Error saving user:', error)
|
||||
setErrors({ general: 'Ошибка при сохранении данных пользователя' })
|
||||
console.error('Ошибка при сохранении пользователя:', error)
|
||||
setErrors({ general: 'Ошибка при сохранении пользователя' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp?: number | null) => {
|
||||
if (!timestamp) return '—'
|
||||
return new Date(timestamp * 1000).toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={loading()} disabled={loading()}>
|
||||
Сохранить изменения
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Редактирование пользователя #${props.user.id}`}
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
footer={footer}
|
||||
size="medium"
|
||||
title={`Редактирование пользователя #${props.user.id}`}
|
||||
size="large"
|
||||
>
|
||||
<div class={styles.form}>
|
||||
{errors().general && (
|
||||
<div class={styles.error} style={{ 'margin-bottom': '20px' }}>
|
||||
{errors().general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информационная секция */}
|
||||
<div
|
||||
class={styles.section}
|
||||
style={{
|
||||
'margin-bottom': '20px',
|
||||
padding: '15px',
|
||||
background: '#f8f9fa',
|
||||
'border-radius': '8px'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 10px 0', color: '#495057' }}>Системная информация</h4>
|
||||
<div style={{ 'font-size': '14px', color: '#6c757d' }}>
|
||||
<div class={formStyles.form}>
|
||||
{/* Компактная системная информация */}
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '1rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--form-bg-light)',
|
||||
'font-size': '0.875rem',
|
||||
color: 'var(--form-text-light)'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>ID:</strong> {props.user.id}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Дата регистрации:</strong> {formatDate(props.user.created_at)}
|
||||
<strong>Регистрация:</strong>{' '}
|
||||
{props.user.created_at
|
||||
? new Date(props.user.created_at * 1000).toLocaleDateString('ru-RU')
|
||||
: '—'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Последняя активность:</strong> {formatDate(props.user.last_seen)}
|
||||
<strong>Активность:</strong>{' '}
|
||||
{props.user.last_seen
|
||||
? new Date(props.user.last_seen * 1000).toLocaleDateString('ru-RU')
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основные данные */}
|
||||
<div class={styles.section}>
|
||||
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>Основные данные</h4>
|
||||
{/* Текущие роли в строку */}
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🎭</span>
|
||||
Текущие роли
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.875rem 1rem',
|
||||
background: isAdmin() ? 'rgba(245, 158, 11, 0.1)' : 'var(--form-bg-light)',
|
||||
border: isAdmin() ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid var(--form-divider)',
|
||||
'font-size': '0.95rem',
|
||||
'font-weight': '500',
|
||||
color: isAdmin() ? '#d97706' : 'var(--form-text)'
|
||||
}}
|
||||
>
|
||||
{getRolesDisplay()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label for="email" class={styles.label}>
|
||||
Email <span style={{ color: 'red' }}>*</span>
|
||||
{/* Основные данные в компактной сетке */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
'grid-template-columns': 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>📧</span>
|
||||
Email
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
class={`${styles.input} ${errors().email ? styles.inputError : ''}`}
|
||||
class={`${formStyles.input} ${errors().email ? formStyles.error : ''}`}
|
||||
value={formData().email}
|
||||
onInput={(e) => updateField('email', e.currentTarget.value)}
|
||||
disabled={loading()}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
{errors().email && <div class={styles.fieldError}>{errors().email}</div>}
|
||||
{errors().email && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().email}
|
||||
</div>
|
||||
)}
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Администраторы определяются автоматически по настройкам сервера
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label for="name" class={styles.label}>
|
||||
Имя <span style={{ color: 'red' }}>*</span>
|
||||
<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
|
||||
id="name"
|
||||
type="text"
|
||||
class={`${styles.input} ${errors().name ? styles.inputError : ''}`}
|
||||
class={`${formStyles.input} ${errors().name ? formStyles.error : ''}`}
|
||||
value={formData().name}
|
||||
onInput={(e) => updateField('name', e.currentTarget.value)}
|
||||
disabled={loading()}
|
||||
placeholder="Иван Иванов"
|
||||
/>
|
||||
{errors().name && <div class={styles.fieldError}>{errors().name}</div>}
|
||||
{errors().name && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label for="slug" class={styles.label}>
|
||||
Slug (URL) <span style={{ color: 'red' }}>*</span>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>🔗</span>
|
||||
Slug (URL)
|
||||
<span class={formStyles.required}>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="slug"
|
||||
type="text"
|
||||
class={`${styles.input} ${errors().slug ? styles.inputError : ''}`}
|
||||
class={`${formStyles.input} ${errors().slug ? formStyles.error : ''}`}
|
||||
value={formData().slug}
|
||||
onInput={(e) => updateField('slug', e.currentTarget.value.toLowerCase())}
|
||||
disabled={loading()}
|
||||
placeholder="ivan-ivanov"
|
||||
/>
|
||||
<div class={styles.fieldHint}>
|
||||
Используется в URL профиля. Только латинские буквы, цифры, дефисы и подчеркивания.
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
Только латинские буквы, цифры, дефисы и подчеркивания
|
||||
</div>
|
||||
{errors().slug && <div class={styles.fieldError}>{errors().slug}</div>}
|
||||
{errors().slug && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().slug}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Роли */}
|
||||
<div class={styles.section}>
|
||||
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>
|
||||
Роли <span style={{ color: 'red' }}>*</span>
|
||||
</h4>
|
||||
<div class={formStyles.fieldGroup}>
|
||||
<label class={formStyles.label}>
|
||||
<span class={formStyles.labelText}>
|
||||
<span class={formStyles.labelIcon}>⚙️</span>
|
||||
Управление ролями
|
||||
<span class={formStyles.required} style={{ display: isAdmin() ? 'none' : 'inline' }}>
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class={styles.rolesGrid}>
|
||||
<div class={formStyles.rolesGrid}>
|
||||
<For each={AVAILABLE_ROLES}>
|
||||
{(role) => (
|
||||
<label
|
||||
class={`${styles.roleCard} ${formData().roles.includes(role.id) ? styles.roleCardSelected : ''}`}
|
||||
class={`${formStyles.roleCard} ${formData().roles.includes(role.id) ? formStyles.roleCardSelected : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -251,18 +350,61 @@ const UserEditModal: Component<UserEditModalProps> = (props) => {
|
||||
disabled={loading()}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div class={styles.roleHeader}>
|
||||
<span class={styles.roleName}>{role.name}</span>
|
||||
<span class={styles.roleCheckmark}>
|
||||
<div class={formStyles.roleHeader}>
|
||||
<span class={formStyles.roleName}>
|
||||
<span style={{ 'margin-right': '0.5rem', 'font-size': '1.1rem' }}>{role.emoji}</span>
|
||||
{role.name}
|
||||
</span>
|
||||
<span class={formStyles.roleCheckmark}>
|
||||
{formData().roles.includes(role.id) ? '✓' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div class={styles.roleDescription}>{role.description}</div>
|
||||
<div class={formStyles.roleDescription}>{role.description}</div>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{errors().roles && <div class={styles.fieldError}>{errors().roles}</div>}
|
||||
|
||||
{!isAdmin() && errors().roles && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().roles}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class={formStyles.hint}>
|
||||
<span class={formStyles.hintIcon}>💡</span>
|
||||
{isAdmin()
|
||||
? 'Администраторы имеют все права автоматически. Дополнительные роли опциональны.'
|
||||
: 'Выберите роли для пользователя. Минимум одна роль обязательна.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Общая ошибка */}
|
||||
{errors().general && (
|
||||
<div class={formStyles.fieldError}>
|
||||
<span class={formStyles.errorIcon}>⚠️</span>
|
||||
{errors().general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Компактные кнопки действий */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
'justify-content': 'flex-end',
|
||||
'margin-top': '1.5rem',
|
||||
'padding-top': '1rem',
|
||||
'border-top': '1px solid var(--form-divider)'
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary" onClick={props.onClose} disabled={loading()}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={loading()}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { Component, For } from 'solid-js'
|
||||
import type { AdminShoutInfo, Maybe, Topic } from '../graphql/generated/schema'
|
||||
import styles from '../styles/Modal.module.css'
|
||||
import CodePreview from '../ui/CodePreview'
|
||||
import Modal from '../ui/Modal'
|
||||
import TextPreview from '../ui/TextPreview'
|
||||
|
||||
export interface ShoutBodyModalProps {
|
||||
shout: AdminShoutInfo
|
||||
@@ -41,7 +41,7 @@ const ShoutBodyModal: Component<ShoutBodyModalProps> = (props) => {
|
||||
<div class={styles['shout-content']}>
|
||||
<h3>Содержание</h3>
|
||||
<div class={styles['content-preview']}>
|
||||
<TextPreview content={props.shout.body || ''} maxHeight="85vh" />
|
||||
<CodePreview content={props.shout.body || ''} maxHeight="85vh" language="html" autoFormat />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,185 +1,346 @@
|
||||
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 { createEffect, createSignal, For, Show } from 'solid-js'
|
||||
import { Topic, useData } from '../context/data'
|
||||
import styles from '../styles/Form.module.css'
|
||||
import modalStyles from '../styles/Modal.module.css'
|
||||
import EditableCodePreview from '../ui/EditableCodePreview'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
interface Topic {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
body?: string
|
||||
pic?: string
|
||||
community: number
|
||||
parent_ids?: number[]
|
||||
}
|
||||
|
||||
interface TopicEditModalProps {
|
||||
topic: Topic
|
||||
isOpen: boolean
|
||||
topic: Topic | null
|
||||
onClose: () => void
|
||||
onSave: (topic: Topic) => void
|
||||
onSave: (updatedTopic: Topic) => void
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное окно для редактирования топиков
|
||||
*/
|
||||
const TopicEditModal: Component<TopicEditModalProps> = (props) => {
|
||||
const [formData, setFormData] = createSignal<Topic>({
|
||||
export default function TopicEditModal(props: TopicEditModalProps) {
|
||||
const { communities, topics, getCommunityName, selectedCommunity } = useData()
|
||||
|
||||
// Состояние формы
|
||||
const [formData, setFormData] = createSignal({
|
||||
id: 0,
|
||||
slug: '',
|
||||
title: '',
|
||||
slug: '',
|
||||
body: '',
|
||||
pic: '',
|
||||
community: 0,
|
||||
parent_ids: []
|
||||
parent_ids: [] as number[]
|
||||
})
|
||||
|
||||
const [parentIdsText, setParentIdsText] = createSignal('')
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
// Состояние для выбора родителей
|
||||
const [availableParents, setAvailableParents] = createSignal<Topic[]>([])
|
||||
const [parentSearch, setParentSearch] = createSignal('')
|
||||
|
||||
// Синхронизация с props.topic
|
||||
// Состояние для редактирования body
|
||||
const [showBodyEditor, setShowBodyEditor] = createSignal(false)
|
||||
const [bodyContent, setBodyContent] = createSignal('')
|
||||
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
// Инициализация формы при открытии
|
||||
createEffect(() => {
|
||||
if (props.topic) {
|
||||
setFormData({ ...props.topic })
|
||||
setParentIdsText(props.topic.parent_ids?.join(', ') || '')
|
||||
|
||||
// Устанавливаем содержимое в contenteditable div
|
||||
if (bodyRef) {
|
||||
bodyRef.innerHTML = props.topic.body || ''
|
||||
}
|
||||
if (props.isOpen && props.topic) {
|
||||
console.log('[TopicEditModal] Initializing with topic:', props.topic)
|
||||
setFormData({
|
||||
id: props.topic.id,
|
||||
title: props.topic.title || '',
|
||||
slug: props.topic.slug || '',
|
||||
body: props.topic.body || '',
|
||||
community: selectedCommunity() || 0,
|
||||
parent_ids: props.topic.parent_ids || []
|
||||
})
|
||||
setBodyContent(props.topic.body || '')
|
||||
updateAvailableParents(selectedCommunity() || 0)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
// Парсим parent_ids из строки
|
||||
const parentIds = parentIdsText()
|
||||
.split(',')
|
||||
.map((id) => Number.parseInt(id.trim()))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
// Обновление доступных родителей при смене сообщества
|
||||
const updateAvailableParents = (communityId: number) => {
|
||||
const allTopics = topics()
|
||||
const currentTopicId = formData().id
|
||||
|
||||
const updatedTopic = {
|
||||
...formData(),
|
||||
parent_ids: parentIds.length > 0 ? parentIds : undefined
|
||||
}
|
||||
// Фильтруем топики того же сообщества, исключая текущий топик
|
||||
const filteredTopics = allTopics.filter(
|
||||
(topic) => topic.community === communityId && topic.id !== currentTopicId
|
||||
)
|
||||
|
||||
props.onSave(updatedTopic)
|
||||
setAvailableParents(filteredTopics)
|
||||
}
|
||||
|
||||
const handleBodyInput = (e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
setFormData((prev) => ({ ...prev, body: target.innerHTML }))
|
||||
// Фильтрация родителей по поиску
|
||||
const filteredParents = () => {
|
||||
const search = parentSearch().toLowerCase()
|
||||
if (!search) return availableParents()
|
||||
|
||||
return availableParents().filter(
|
||||
(topic) => topic.title?.toLowerCase().includes(search) || topic.slug?.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
|
||||
// Обработка изменения сообщества
|
||||
const handleCommunityChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement
|
||||
const communityId = Number.parseInt(target.value)
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
community: communityId,
|
||||
parent_ids: [] // Сбрасываем родителей при смене сообщества
|
||||
}))
|
||||
|
||||
updateAvailableParents(communityId)
|
||||
}
|
||||
|
||||
// Обработка изменения родителей
|
||||
const handleParentToggle = (parentId: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
parent_ids: prev.parent_ids.includes(parentId)
|
||||
? prev.parent_ids.filter((id) => id !== parentId)
|
||||
: [...prev.parent_ids, parentId]
|
||||
}))
|
||||
}
|
||||
|
||||
// Обработка изменения полей формы
|
||||
const handleFieldChange = (field: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
// Открытие редактора body
|
||||
const handleOpenBodyEditor = () => {
|
||||
setBodyContent(formData().body)
|
||||
setShowBodyEditor(true)
|
||||
}
|
||||
|
||||
// Сохранение body из редактора
|
||||
const handleBodySave = (content: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
body: content
|
||||
}))
|
||||
setBodyContent(content)
|
||||
setShowBodyEditor(false)
|
||||
}
|
||||
|
||||
// Получение пути до корня для топика
|
||||
const getTopicPath = (topicId: number): string => {
|
||||
const topic = topics().find((t) => t.id === topicId)
|
||||
if (!topic) return 'Неизвестный топик'
|
||||
|
||||
const community = getCommunityName(topic.community)
|
||||
return `${community} → ${topic.title}`
|
||||
}
|
||||
|
||||
// Сохранение изменений
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
|
||||
const updatedTopic = {
|
||||
...props.topic,
|
||||
...formData()
|
||||
}
|
||||
|
||||
console.log('[TopicEditModal] Saving topic:', updatedTopic)
|
||||
|
||||
// TODO: Здесь должен быть вызов API для сохранения
|
||||
// await updateTopic(updatedTopic)
|
||||
|
||||
props.onSave(updatedTopic)
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
console.error('[TopicEditModal] Error saving topic:', error)
|
||||
props.onError?.(error instanceof Error ? error.message : 'Ошибка сохранения топика')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
title={`Редактирование топика: ${props.topic?.title || ''}`}
|
||||
>
|
||||
<div class={styles['modal-content']}>
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().id}
|
||||
disabled
|
||||
class={formStyles.input}
|
||||
style={{ background: '#f5f5f5', cursor: 'not-allowed' }}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<Modal
|
||||
isOpen={props.isOpen && !showBodyEditor()}
|
||||
onClose={props.onClose}
|
||||
title="Редактирование топика"
|
||||
size="large"
|
||||
>
|
||||
<div class={styles.form}>
|
||||
{/* Основная информация */}
|
||||
<div class={styles.section}>
|
||||
<h3>Основная информация</h3>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().slug}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
class={formStyles.input}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Название:
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
value={formData().title}
|
||||
onInput={(e) => handleFieldChange('title', e.currentTarget.value)}
|
||||
placeholder="Введите название топика..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().title}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
class={formStyles.input}
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Slug:
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
value={formData().slug}
|
||||
onInput={(e) => handleFieldChange('slug', e.currentTarget.value)}
|
||||
placeholder="Введите slug топика..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>Описание (HTML)</label>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
contentEditable
|
||||
onInput={handleBodyInput}
|
||||
class={formStyles.input}
|
||||
style={{
|
||||
'min-height': '120px',
|
||||
'font-family': 'Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
'font-size': '13px',
|
||||
'line-height': '1.4',
|
||||
'white-space': 'pre-wrap',
|
||||
'overflow-wrap': 'break-word'
|
||||
}}
|
||||
data-placeholder="Введите HTML описание топика..."
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Сообщество:
|
||||
<select class={styles.select} value={formData().community} onChange={handleCommunityChange}>
|
||||
<option value={0}>Выберите сообщество</option>
|
||||
<For each={communities()}>
|
||||
{(community) => <option value={community.id}>{community.name}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>Картинка (URL)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().pic || ''}
|
||||
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
|
||||
class={formStyles.input}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
{/* Содержимое */}
|
||||
<div class={styles.section}>
|
||||
<h3>Содержимое</h3>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>Сообщество (ID)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData().community}
|
||||
onInput={(e) =>
|
||||
setFormData((prev) => ({ ...prev, community: Number.parseInt(e.target.value) || 0 }))
|
||||
}
|
||||
class={formStyles.input}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>Body:</label>
|
||||
<div class={styles.bodyPreview} onClick={handleOpenBodyEditor}>
|
||||
<Show when={formData().body}>
|
||||
<div class={styles.bodyContent}>
|
||||
{formData().body.length > 200
|
||||
? `${formData().body.substring(0, 200)}...`
|
||||
: formData().body}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!formData().body}>
|
||||
<div class={styles.bodyPlaceholder}>Нет содержимого. Нажмите для редактирования.</div>
|
||||
</Show>
|
||||
<div class={styles.bodyHint}>✏️ Кликните для редактирования в полноэкранном редакторе</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formStyles['form-group']}>
|
||||
<label class={formStyles.label}>
|
||||
Родительские топики (ID через запятую)
|
||||
<small style={{ display: 'block', color: '#666', 'margin-top': '4px' }}>
|
||||
Например: 1, 5, 12
|
||||
</small>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={parentIdsText()}
|
||||
onInput={(e) => setParentIdsText(e.target.value)}
|
||||
class={formStyles.input}
|
||||
placeholder="1, 5, 12"
|
||||
/>
|
||||
</div>
|
||||
{/* Родительские топики */}
|
||||
<Show when={formData().community > 0}>
|
||||
<div class={styles.section}>
|
||||
<h3>Родительские топики</h3>
|
||||
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={props.onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Поиск родителей:
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
value={parentSearch()}
|
||||
onInput={(e) => setParentSearch(e.currentTarget.value)}
|
||||
placeholder="Введите название для поиска..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={formData().parent_ids.length > 0}>
|
||||
<div class={styles.selectedParents}>
|
||||
<strong>Выбранные родители:</strong>
|
||||
<ul class={styles.parentsList}>
|
||||
<For each={formData().parent_ids}>
|
||||
{(parentId) => (
|
||||
<li class={styles.parentItem}>
|
||||
<span>{getTopicPath(parentId)}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.removeButton}
|
||||
onClick={() => handleParentToggle(parentId)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.availableParents}>
|
||||
<strong>Доступные родители:</strong>
|
||||
<div class={styles.parentsGrid}>
|
||||
<For each={filteredParents()}>
|
||||
{(parent) => (
|
||||
<label class={styles.parentCheckbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData().parent_ids.includes(parent.id)}
|
||||
onChange={() => handleParentToggle(parent.id)}
|
||||
/>
|
||||
<span class={styles.parentLabel}>
|
||||
<strong>{parent.title}</strong>
|
||||
<br />
|
||||
<small>{parent.slug}</small>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={filteredParents().length === 0}>
|
||||
<div class={styles.noParents}>
|
||||
<Show when={parentSearch()}>Не найдено топиков по запросу "{parentSearch()}"</Show>
|
||||
<Show when={!parentSearch()}>Нет доступных родительских топиков в этом сообществе</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div class={modalStyles.modalActions}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.button} ${styles.buttonSecondary}`}
|
||||
onClick={props.onClose}
|
||||
disabled={saving()}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.button} ${styles.buttonPrimary}`}
|
||||
onClick={handleSave}
|
||||
disabled={saving() || !formData().title || !formData().slug || formData().community === 0}
|
||||
>
|
||||
{saving() ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
|
||||
{/* Редактор body */}
|
||||
<Modal
|
||||
isOpen={showBodyEditor()}
|
||||
onClose={() => setShowBodyEditor(false)}
|
||||
title="Редактирование содержимого топика"
|
||||
size="large"
|
||||
>
|
||||
<EditableCodePreview
|
||||
content={bodyContent()}
|
||||
maxHeight="85vh"
|
||||
onContentChange={setBodyContent}
|
||||
onSave={handleBodySave}
|
||||
onCancel={() => setShowBodyEditor(false)}
|
||||
placeholder="Введите содержимое топика..."
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicEditModal
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, createSignal, For, JSX, Show } from 'solid-js'
|
||||
import { createSignal, For, JSX, Show } from 'solid-js'
|
||||
import styles from '../styles/Form.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
@@ -262,7 +262,13 @@ const TopicHierarchyModal = (props: TopicHierarchyModalProps) => {
|
||||
'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<Show when={hasChildren}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
Reference in New Issue
Block a user