This commit is contained in:
@@ -2,6 +2,7 @@ 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 HTMLEditor from '../ui/HTMLEditor'
|
||||
import Modal from '../ui/Modal'
|
||||
|
||||
interface Collection {
|
||||
@@ -166,12 +167,9 @@ const CollectionEditModal: Component<CollectionEditModalProps> = (props) => {
|
||||
Описание
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
class={formStyles.textarea}
|
||||
<HTMLEditor
|
||||
value={formData().desc}
|
||||
onInput={(e) => updateField('desc', e.target.value)}
|
||||
placeholder="Описание коллекции (необязательно)"
|
||||
rows="4"
|
||||
onInput={(value) => updateField('desc', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@@ -11,6 +11,7 @@ import styles from '../styles/Modal.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
import RoleManager from '../ui/RoleManager'
|
||||
import HTMLEditor from '../ui/HTMLEditor'
|
||||
|
||||
interface Community {
|
||||
id: number
|
||||
@@ -284,12 +285,9 @@ const CommunityEditModal = (props: CommunityEditModalProps) => {
|
||||
Описание
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
class={formStyles.textarea}
|
||||
<HTMLEditor
|
||||
value={formData().desc || ''}
|
||||
onInput={(e) => updateField('desc', e.currentTarget.value)}
|
||||
placeholder="Описание сообщества"
|
||||
rows={4}
|
||||
onInput={(value) => updateField('desc', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import { createEffect, createSignal, For, Show } from 'solid-js'
|
||||
import { createEffect, createSignal, For, Show, on } from 'solid-js'
|
||||
import { Topic, useData } from '../context/data'
|
||||
import { query } from '../graphql'
|
||||
import { ADMIN_UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||
import styles from '../styles/Form.module.css'
|
||||
import modalStyles from '../styles/Modal.module.css'
|
||||
import EditableCodePreview from '../ui/EditableCodePreview'
|
||||
import Modal from '../ui/Modal'
|
||||
import TopicPillsCloud, { type TopicPill } from '../ui/TopicPillsCloud'
|
||||
import HTMLEditor from '../ui/HTMLEditor'
|
||||
|
||||
interface TopicEditModalProps {
|
||||
topic: Topic
|
||||
@@ -28,35 +30,36 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||
|
||||
// Состояние для выбора родителей
|
||||
const [availableParents, setAvailableParents] = createSignal<Topic[]>([])
|
||||
const [parentSearch, setParentSearch] = createSignal('')
|
||||
|
||||
// Состояние для редактирования body
|
||||
const [showBodyEditor, setShowBodyEditor] = createSignal(false)
|
||||
const [bodyContent, setBodyContent] = createSignal('')
|
||||
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
// Инициализация формы при открытии
|
||||
createEffect(() => {
|
||||
if (props.isOpen && props.topic) {
|
||||
console.log('[TopicEditModal] Initializing with topic:', props.topic)
|
||||
const topicCommunity = props.topic.community || selectedCommunity() || 0
|
||||
setFormData({
|
||||
id: props.topic.id,
|
||||
title: props.topic.title || '',
|
||||
slug: props.topic.slug || '',
|
||||
body: props.topic.body || '',
|
||||
community: selectedCommunity() || 0,
|
||||
community: topicCommunity,
|
||||
parent_ids: props.topic.parent_ids || []
|
||||
})
|
||||
setBodyContent(props.topic.body || '')
|
||||
updateAvailableParents(selectedCommunity() || 0)
|
||||
updateAvailableParents(topicCommunity, props.topic.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Обновление доступных родителей при изменении сообщества в форме
|
||||
createEffect(on(() => formData().community, (communityId) => {
|
||||
if (communityId > 0) {
|
||||
updateAvailableParents(communityId)
|
||||
}
|
||||
}))
|
||||
|
||||
// Обновление доступных родителей при смене сообщества
|
||||
const updateAvailableParents = (communityId: number) => {
|
||||
const updateAvailableParents = (communityId: number, excludeTopicId?: number) => {
|
||||
const allTopics = topics()
|
||||
const currentTopicId = formData().id
|
||||
const currentTopicId = excludeTopicId || formData().id
|
||||
|
||||
// Фильтруем топики того же сообщества, исключая текущий топик
|
||||
const filteredTopics = allTopics.filter(
|
||||
@@ -66,40 +69,32 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||
setAvailableParents(filteredTopics)
|
||||
}
|
||||
|
||||
// Фильтрация родителей по поиску
|
||||
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]
|
||||
/**
|
||||
* Преобразование Topic в TopicPill для компонента TopicPillsCloud
|
||||
*/
|
||||
const convertTopicsToTopicPills = (topics: Topic[]): TopicPill[] => {
|
||||
return topics.map(topic => ({
|
||||
id: topic.id.toString(),
|
||||
title: topic.title || '',
|
||||
slug: topic.slug || '',
|
||||
community: getCommunityName(topic.community),
|
||||
parent_ids: (topic.parent_ids || []).map(id => id.toString()),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка изменения выбора родительских топиков из таблеточек
|
||||
*/
|
||||
const handleParentSelectionChange = (selectedIds: string[]) => {
|
||||
const parentIds = selectedIds.map(id => Number.parseInt(id))
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
parent_ids: parentIds
|
||||
}))
|
||||
}
|
||||
|
||||
// Сообщество топика изменить нельзя, поэтому обработчик не нужен
|
||||
|
||||
// Обработка изменения полей формы
|
||||
const handleFieldChange = (field: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
@@ -108,48 +103,39 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||
}))
|
||||
}
|
||||
|
||||
// Открытие редактора 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()
|
||||
const topicData = formData()
|
||||
|
||||
// Вызываем админскую мутацию для сохранения
|
||||
const result = await query<{
|
||||
adminUpdateTopic: {
|
||||
success: boolean
|
||||
error?: string
|
||||
topic?: Topic
|
||||
}
|
||||
}>(`${location.origin}/graphql`, ADMIN_UPDATE_TOPIC_MUTATION, {
|
||||
topic: {
|
||||
id: topicData.id,
|
||||
title: topicData.title,
|
||||
slug: topicData.slug,
|
||||
body: topicData.body,
|
||||
community: topicData.community,
|
||||
parent_ids: topicData.parent_ids
|
||||
}
|
||||
})
|
||||
|
||||
if (result.adminUpdateTopic.success && result.adminUpdateTopic.topic) {
|
||||
console.log('[TopicEditModal] Topic saved successfully:', result.adminUpdateTopic.topic)
|
||||
props.onSave(result.adminUpdateTopic.topic)
|
||||
props.onClose()
|
||||
} else {
|
||||
const errorMessage = result.adminUpdateTopic.error || 'Неизвестная ошибка'
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
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 : 'Ошибка сохранения топика')
|
||||
@@ -161,16 +147,34 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={props.isOpen && !showBodyEditor()}
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
title="Редактирование топика"
|
||||
size="large"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.button} ${styles.secondary}`}
|
||||
onClick={props.onClose}
|
||||
disabled={saving()}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.button} ${styles.primary}`}
|
||||
onClick={handleSave}
|
||||
disabled={saving() || !formData().title || !formData().slug || formData().community === 0}
|
||||
>
|
||||
{saving() ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class={styles.form}>
|
||||
{/* Основная информация */}
|
||||
<div class={styles.section}>
|
||||
<h3>Основная информация</h3>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Название:
|
||||
@@ -200,147 +204,50 @@ export default function TopicEditModal(props: TopicEditModalProps) {
|
||||
<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>
|
||||
<div class={`${styles.input} ${styles.disabled} ${styles.communityDisplay}`}>
|
||||
{getCommunityName(formData().community) || 'Сообщество не выбрано'}
|
||||
</div>
|
||||
</label>
|
||||
<div class={`${styles.hint} ${styles.warningHint}`}>
|
||||
📍 Сообщество топика нельзя изменить после создания
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Содержимое */}
|
||||
<div class={styles.section}>
|
||||
<h3>Содержимое</h3>
|
||||
|
||||
<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>
|
||||
<label class={styles.label}>
|
||||
Описание:
|
||||
<HTMLEditor
|
||||
value={formData().body}
|
||||
onInput={(value) => handleFieldChange('body', value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Родительские топики */}
|
||||
<Show when={formData().community > 0}>
|
||||
<div class={styles.section}>
|
||||
<h3>Родительские топики</h3>
|
||||
|
||||
{/* Компонент с таблеточками для выбора родителей */}
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Поиск родителей:
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
value={parentSearch()}
|
||||
onInput={(e) => setParentSearch(e.currentTarget.value)}
|
||||
placeholder="Введите название для поиска..."
|
||||
<TopicPillsCloud
|
||||
topics={convertTopicsToTopicPills(availableParents())}
|
||||
selectedTopics={formData().parent_ids.map(id => id.toString())}
|
||||
onSelectionChange={handleParentSelectionChange}
|
||||
excludeTopics={[formData().id.toString()]}
|
||||
showSearch={true}
|
||||
searchPlaceholder="Задайте родительские темы..."
|
||||
hideSelectedInHeader={true}
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -9,7 +9,9 @@ interface Topic {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
body?: string
|
||||
community: number
|
||||
parent_ids?: number[]
|
||||
stat?: {
|
||||
shouts: number
|
||||
followers: number
|
||||
@@ -33,25 +35,112 @@ interface MergeStats {
|
||||
source_topics_deleted: number
|
||||
}
|
||||
|
||||
interface ValidationErrors {
|
||||
target?: string
|
||||
sources?: string
|
||||
general?: string
|
||||
}
|
||||
|
||||
const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
const [targetTopicId, setTargetTopicId] = createSignal<number | null>(null)
|
||||
const [sourceTopicIds, setSourceTopicIds] = createSignal<number[]>([])
|
||||
const [preserveTarget, setPreserveTarget] = createSignal(true)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal('')
|
||||
const [errors, setErrors] = createSignal<ValidationErrors>({})
|
||||
const [searchQuery, setSearchQuery] = createSignal('')
|
||||
|
||||
/**
|
||||
* Получает токен авторизации из localStorage или cookie
|
||||
*/
|
||||
const getAuthTokenFromCookie = () => {
|
||||
return (
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('auth_token='))
|
||||
?.split('=')[1] || ''
|
||||
const getAuthToken = () => {
|
||||
return localStorage.getItem('auth_token') ||
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('auth_token='))
|
||||
?.split('=')[1] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидация данных для слияния
|
||||
*/
|
||||
const validateMergeData = (): ValidationErrors => {
|
||||
const newErrors: ValidationErrors = {}
|
||||
|
||||
const target = targetTopicId()
|
||||
const sources = sourceTopicIds()
|
||||
|
||||
if (!target) {
|
||||
newErrors.target = 'Необходимо выбрать целевую тему'
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
newErrors.sources = 'Необходимо выбрать хотя бы одну исходную тему'
|
||||
} else if (sources.length > 10) {
|
||||
newErrors.sources = 'Нельзя объединять более 10 тем за раз'
|
||||
}
|
||||
|
||||
// Проверяем что целевая тема не выбрана как исходная
|
||||
if (target && sources.includes(target)) {
|
||||
newErrors.general = 'Целевая тема не может быть в списке исходных тем'
|
||||
}
|
||||
|
||||
// Проверяем что все темы принадлежат одному сообществу
|
||||
if (target && sources.length > 0) {
|
||||
const targetTopic = props.topics.find((t) => t.id === target)
|
||||
const sourcesTopics = props.topics.filter((t) => sources.includes(t.id))
|
||||
|
||||
if (targetTopic) {
|
||||
const targetCommunity = targetTopic.community
|
||||
const invalidSources = sourcesTopics.filter(topic => topic.community !== targetCommunity)
|
||||
|
||||
if (invalidSources.length > 0) {
|
||||
newErrors.general = `Все темы должны принадлежать одному сообществу. Темы ${invalidSources.map(t => `"${t.title}"`).join(', ')} принадлежат другому сообществу`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newErrors
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает название сообщества по ID
|
||||
*/
|
||||
const getCommunityName = (communityId: number): string => {
|
||||
// Заглушка - можно добавить запрос к API или кеш сообществ
|
||||
return `Сообщество ${communityId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить отфильтрованный список топиков для поиска
|
||||
*/
|
||||
const getFilteredTopics = (topicsList: Topic[]) => {
|
||||
const query = searchQuery().toLowerCase().trim()
|
||||
if (!query) return topicsList
|
||||
|
||||
return topicsList.filter(topic =>
|
||||
topic.title?.toLowerCase().includes(query) ||
|
||||
topic.slug?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик выбора целевой темы
|
||||
*/
|
||||
const handleTargetTopicChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement
|
||||
const topicId = target.value ? Number.parseInt(target.value) : null
|
||||
setTargetTopicId(topicId)
|
||||
|
||||
// Убираем выбранную целевую тему из исходных тем
|
||||
if (topicId) {
|
||||
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
|
||||
}
|
||||
|
||||
// Перевалидация
|
||||
const newErrors = validateMergeData()
|
||||
setErrors(newErrors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик выбора/снятия выбора исходной темы
|
||||
*/
|
||||
@@ -61,40 +150,44 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
} else {
|
||||
setSourceTopicIds((prev) => prev.filter((id) => id !== topicId))
|
||||
}
|
||||
|
||||
// Перевалидация
|
||||
const newErrors = validateMergeData()
|
||||
setErrors(newErrors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет можно ли выполнить слияние
|
||||
*/
|
||||
const canMerge = () => {
|
||||
const target = targetTopicId()
|
||||
const sources = sourceTopicIds()
|
||||
|
||||
if (!target || sources.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Проверяем что целевая тема не выбрана как исходная
|
||||
if (sources.includes(target)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Проверяем что все темы принадлежат одному сообществу
|
||||
const targetTopic = props.topics.find((t) => t.id === target)
|
||||
if (!targetTopic) return false
|
||||
|
||||
const targetCommunity = targetTopic.community
|
||||
const sourcesTopics = props.topics.filter((t) => sources.includes(t.id))
|
||||
|
||||
return sourcesTopics.every((topic) => topic.community === targetCommunity)
|
||||
const validationErrors = validateMergeData()
|
||||
return Object.keys(validationErrors).length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает название сообщества по ID (заглушка)
|
||||
* Получить статистику для предварительного просмотра
|
||||
*/
|
||||
const getCommunityName = (communityId: number) => {
|
||||
// Здесь можно добавить запрос к API или кеш сообществ
|
||||
return `Сообщество ${communityId}`
|
||||
const getMergePreview = () => {
|
||||
const target = targetTopicId()
|
||||
const sources = sourceTopicIds()
|
||||
|
||||
if (!target || sources.length === 0) return null
|
||||
|
||||
const targetTopic = props.topics.find(t => t.id === target)
|
||||
const sourceTopics = props.topics.filter(t => sources.includes(t.id))
|
||||
|
||||
const totalShouts = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.shouts || 0), 0)
|
||||
const totalFollowers = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.followers || 0), 0)
|
||||
const totalAuthors = sourceTopics.reduce((sum, topic) => sum + (topic.stat?.authors || 0), 0)
|
||||
|
||||
return {
|
||||
targetTopic,
|
||||
sourceTopics,
|
||||
totalShouts,
|
||||
totalFollowers,
|
||||
totalAuthors,
|
||||
sourcesCount: sources.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,15 +195,16 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
*/
|
||||
const handleMerge = async () => {
|
||||
if (!canMerge()) {
|
||||
setError('Невозможно выполнить слияние с текущими настройками')
|
||||
const validationErrors = validateMergeData()
|
||||
setErrors(validationErrors)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setErrors({})
|
||||
|
||||
try {
|
||||
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
||||
const authToken = getAuthToken()
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
@@ -151,7 +245,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message
|
||||
setError(errorMessage)
|
||||
setErrors({ general: errorMessage })
|
||||
props.onError(`Ошибка слияния тем: ${errorMessage}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -165,8 +259,9 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
setTargetTopicId(null)
|
||||
setSourceTopicIds([])
|
||||
setPreserveTarget(true)
|
||||
setError('')
|
||||
setErrors({})
|
||||
setLoading(false)
|
||||
setSearchQuery('')
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -186,65 +281,115 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
return props.topics.filter((topic) => topic.id !== target)
|
||||
}
|
||||
|
||||
const preview = getMergePreview()
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
|
||||
<div class={styles.form}>
|
||||
|
||||
{/* Общие ошибки */}
|
||||
<Show when={errors().general}>
|
||||
<div class={styles.formError}>
|
||||
{errors().general}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Выбор целевой темы */}
|
||||
<div class={styles.section}>
|
||||
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3>
|
||||
<p class={styles.description}>
|
||||
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут
|
||||
перенесены в эту тему.
|
||||
<h3 class={styles.sectionTitle}>🎯 Целевая тема</h3>
|
||||
<p class={styles.sectionDescription}>
|
||||
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации
|
||||
будут перенесены в эту тему, а исходные темы будут удалены.
|
||||
</p>
|
||||
|
||||
<select
|
||||
value={targetTopicId() || ''}
|
||||
onChange={(e) => setTargetTopicId(e.target.value ? Number.parseInt(e.target.value) : null)}
|
||||
class={styles.select}
|
||||
disabled={loading()}
|
||||
>
|
||||
<option value="">Выберите целевую тему</option>
|
||||
<For each={getAvailableTargetTopics()}>
|
||||
{(topic) => (
|
||||
<option value={topic.id}>
|
||||
{topic.title} ({getCommunityName(topic.community)})
|
||||
{topic.stat ? ` - ${topic.stat.shouts} публ., ${topic.stat.followers} подп.` : ''}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Целевая тема:
|
||||
<select
|
||||
class={`${styles.select} ${errors().target ? styles.inputError : ''}`}
|
||||
value={targetTopicId() || ''}
|
||||
onChange={handleTargetTopicChange}
|
||||
required
|
||||
>
|
||||
<option value="">Выберите целевую тему...</option>
|
||||
<For each={getFilteredTopics(getAvailableTargetTopics())}>
|
||||
{(topic) => (
|
||||
<option value={topic.id}>
|
||||
{topic.title} ({topic.slug})
|
||||
{topic.stat ? ` • ${topic.stat.shouts} публикаций` : ''}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<Show when={errors().target}>
|
||||
<div class={styles.errorMessage}>{errors().target}</div>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поиск и выбор исходных тем */}
|
||||
<div class={styles.section}>
|
||||
<h3 class={styles.sectionTitle}>Выбор исходных тем для слияния</h3>
|
||||
<p class={styles.description}>
|
||||
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех
|
||||
связей.
|
||||
<h3 class={styles.sectionTitle}>📥 Исходные темы</h3>
|
||||
<p class={styles.sectionDescription}>
|
||||
Выберите темы, которые будут слиты в целевую тему. Все их данные будут перенесены,
|
||||
а сами темы будут удалены.
|
||||
</p>
|
||||
|
||||
<Show when={getAvailableSourceTopics().length > 0}>
|
||||
<div class={styles.checkboxList}>
|
||||
<For each={getAvailableSourceTopics()}>
|
||||
<div class={styles.field}>
|
||||
<label class={styles.label}>
|
||||
Поиск тем:
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder="Введите название или slug для поиска..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={errors().sources}>
|
||||
<div class={styles.errorMessage}>{errors().sources}</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.availableParents}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<strong>Доступные темы для слияния:</strong>
|
||||
<span class={styles.hint}>
|
||||
Выбрано: {sourceTopicIds().length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.parentsGrid}>
|
||||
<For each={getFilteredTopics(getAvailableSourceTopics())}>
|
||||
{(topic) => {
|
||||
const isChecked = () => sourceTopicIds().includes(topic.id)
|
||||
const isDisabled = () => targetTopicId() && topic.community !== props.topics.find(t => t.id === targetTopicId())?.community
|
||||
|
||||
return (
|
||||
<label class={styles.checkboxItem}>
|
||||
<label
|
||||
class={`${styles.parentCheckbox} ${isDisabled() ? styles.disabled : ''}`}
|
||||
title={isDisabled() ? 'Тема принадлежит другому сообществу' : ''}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked()}
|
||||
onChange={(e) => handleSourceTopicToggle(topic.id, e.target.checked)}
|
||||
disabled={loading()}
|
||||
class={styles.checkbox}
|
||||
disabled={isDisabled() || false}
|
||||
onChange={(e) => handleSourceTopicToggle(topic.id, e.currentTarget.checked)}
|
||||
/>
|
||||
<div class={styles.checkboxContent}>
|
||||
<div class={styles.topicTitle}>{topic.title}</div>
|
||||
<div class={styles.topicInfo}>
|
||||
{getCommunityName(topic.community)} • ID: {topic.id}
|
||||
<div class={styles.parentLabel}>
|
||||
<div class={styles.parentTitle}>
|
||||
{topic.title}
|
||||
</div>
|
||||
<div class={styles.parentSlug}>{topic.slug}</div>
|
||||
<div class={styles.parentStats}>
|
||||
{getCommunityName(topic.community)}
|
||||
{topic.stat && (
|
||||
<span>
|
||||
{' '}
|
||||
• {topic.stat.shouts} публ., {topic.stat.followers} подп.
|
||||
</span>
|
||||
<>
|
||||
<span> • {topic.stat.shouts} публикаций</span>
|
||||
<span> • {topic.stat.followers} подписчиков</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,65 +398,91 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={getFilteredTopics(getAvailableSourceTopics()).length === 0}>
|
||||
<div class={styles.noParents}>
|
||||
<Show when={searchQuery()}>
|
||||
Не найдено тем по запросу "{searchQuery()}"
|
||||
</Show>
|
||||
<Show when={!searchQuery()}>
|
||||
Нет доступных тем для слияния
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.section}>
|
||||
<h3 class={styles.sectionTitle}>Настройки слияния</h3>
|
||||
{/* Предварительный просмотр слияния */}
|
||||
<Show when={preview}>
|
||||
<div class={styles.section}>
|
||||
<h3 class={styles.sectionTitle}>📊 Предварительный просмотр</h3>
|
||||
|
||||
<label class={styles.checkboxItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveTarget()}
|
||||
onChange={(e) => setPreserveTarget(e.target.checked)}
|
||||
disabled={loading()}
|
||||
class={styles.checkbox}
|
||||
/>
|
||||
<div class={styles.checkboxContent}>
|
||||
<div class={styles.optionTitle}>Сохранить свойства целевой темы</div>
|
||||
<div class={styles.optionDescription}>
|
||||
Если отключено, будут объединены parent_ids из всех тем
|
||||
<div class={styles.hierarchyPath}>
|
||||
<div><strong>Целевая тема:</strong> {preview!.targetTopic!.title}</div>
|
||||
<div class={styles.pathDisplay}>
|
||||
<span>Слияние {preview!.sourcesCount} тем:</span>
|
||||
<For each={preview!.sourceTopics}>
|
||||
{(topic) => (
|
||||
<span class={styles.pathItem}>{topic.title}</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<strong>Ожидаемые результаты:</strong>
|
||||
<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||
<li>Будет перенесено ~{preview!.totalShouts} публикаций</li>
|
||||
<li>Будет перенесено ~{preview!.totalFollowers} подписчиков</li>
|
||||
<li>Будет объединено ~{preview!.totalAuthors} авторов</li>
|
||||
<li>Будет удалено {preview!.sourcesCount} исходных тем</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class={styles.error}>{error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={targetTopicId() && sourceTopicIds().length > 0}>
|
||||
<div class={styles.summary}>
|
||||
<h4>Предпросмотр слияния:</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Целевая тема:</strong> {props.topics.find((t) => t.id === targetTopicId())?.title}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Исходные темы:</strong> {sourceTopicIds().length} шт.
|
||||
<ul>
|
||||
<For each={sourceTopicIds()}>
|
||||
{(id) => {
|
||||
const topic = props.topics.find((t) => t.id === id)
|
||||
return topic ? <li>{topic.title}</li> : null
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Действие:</strong> Все подписчики, публикации и черновики будут перенесены в целевую
|
||||
тему, исходные темы будут удалены
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.modalActions}>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading()}>
|
||||
{/* Настройки слияния */}
|
||||
<div class={styles.section}>
|
||||
<h3 class={styles.sectionTitle}>⚙️ Настройки слияния</h3>
|
||||
|
||||
<div class={styles.field}>
|
||||
<label class={styles.parentCheckbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveTarget()}
|
||||
onChange={(e) => setPreserveTarget(e.currentTarget.checked)}
|
||||
/>
|
||||
<div class={styles.parentLabel}>
|
||||
<div class={styles.parentTitle}>
|
||||
Сохранить свойства целевой темы
|
||||
</div>
|
||||
<div class={styles.parentStats}>
|
||||
Если включено, описание и другие свойства целевой темы не будут изменены.
|
||||
Если выключено, свойства могут быть объединены с исходными темами.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div class={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={loading()}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleMerge} disabled={!canMerge() || loading()}>
|
||||
{loading() ? 'Выполняется слияние...' : 'Слить темы'}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleMerge}
|
||||
disabled={!canMerge() || loading()}
|
||||
loading={loading()}
|
||||
>
|
||||
{loading() ? 'Выполняется слияние...' : `Слить ${sourceTopicIds().length} тем`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user