simpler-parent-select

This commit is contained in:
2025-07-01 01:20:48 +03:00
parent 2683982180
commit bb41c02d62
18 changed files with 3281 additions and 49 deletions

View File

@@ -0,0 +1,326 @@
import { Component, createSignal, For, Show } from 'solid-js'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
interface Topic {
id: number
title: string
slug: string
parent_ids?: number[]
community: number
}
interface TopicBulkParentModalProps {
isOpen: boolean
onClose: () => void
selectedTopicIds: number[]
allTopics: Topic[]
onSave: (changes: BulkParentChange[]) => void
onError: (error: string) => void
}
interface BulkParentChange {
topicId: number
newParentIds: number[]
oldParentIds: number[]
}
const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
const [newParentId, setNewParentId] = createSignal<number | null>(null)
const [searchQuery, setSearchQuery] = createSignal('')
const [actionType, setActionType] = createSignal<'set' | 'makeRoot'>('set')
// Получаем выбранные топики
const getSelectedTopics = () => {
return props.allTopics.filter(topic =>
props.selectedTopicIds.includes(topic.id)
)
}
// Фильтрация доступных родителей
const getAvailableParents = () => {
const selectedIds = new Set(props.selectedTopicIds)
return props.allTopics.filter(topic => {
// Исключаем выбранные топики
if (selectedIds.has(topic.id)) return false
// Исключаем топики, которые являются детьми выбранных
const isChildOfSelected = props.selectedTopicIds.some(selectedId =>
isDescendant(selectedId, topic.id)
)
if (isChildOfSelected) return false
// Фильтр по поисковому запросу
const query = searchQuery().toLowerCase()
if (query && !topic.title.toLowerCase().includes(query)) return false
return true
})
}
// Проверка, является ли топик потомком другого
const isDescendant = (ancestorId: number, descendantId: number): boolean => {
const descendant = props.allTopics.find(t => t.id === descendantId)
if (!descendant || !descendant.parent_ids) return false
return descendant.parent_ids.includes(ancestorId)
}
// Получение пути к корню
const getTopicPath = (topicId: number): string => {
const topic = props.allTopics.find(t => t.id === topicId)
if (!topic) return ''
if (!topic.parent_ids || topic.parent_ids.length === 0) {
return topic.title
}
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
return `${parentPath}${topic.title}`
}
// Группировка топиков по сообществам
const getTopicsByCommunity = () => {
const selectedTopics = getSelectedTopics()
const communities = new Map<number, Topic[]>()
selectedTopics.forEach(topic => {
if (!communities.has(topic.community)) {
communities.set(topic.community, [])
}
communities.get(topic.community)!.push(topic)
})
return communities
}
// Проверка совместимости действия
const validateAction = (): string | null => {
const communities = getTopicsByCommunity()
if (communities.size > 1) {
return 'Нельзя изменять иерархию тем из разных сообществ одновременно'
}
if (actionType() === 'set' && !newParentId()) {
return 'Выберите родительскую тему'
}
const selectedParent = props.allTopics.find(t => t.id === newParentId())
if (selectedParent) {
const selectedCommunity = Array.from(communities.keys())[0]
if (selectedParent.community !== selectedCommunity) {
return 'Родительская тема должна быть из того же сообщества'
}
}
return null
}
// Сохранение изменений
const handleSave = () => {
const validationError = validateAction()
if (validationError) {
props.onError(validationError)
return
}
const changes: BulkParentChange[] = []
const selectedTopics = getSelectedTopics()
selectedTopics.forEach(topic => {
let newParentIds: number[] = []
if (actionType() === 'set' && newParentId()) {
const parentTopic = props.allTopics.find(t => t.id === newParentId())
if (parentTopic) {
newParentIds = [...(parentTopic.parent_ids || []), newParentId()!]
}
}
changes.push({
topicId: topic.id,
newParentIds,
oldParentIds: topic.parent_ids || []
})
})
props.onSave(changes)
}
return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
title={`Массовое изменение иерархии (${props.selectedTopicIds.length} тем)`}
size="large"
>
<div class={styles.bulkParentContainer}>
{/* Проверка совместимости */}
<Show when={getTopicsByCommunity().size > 1}>
<div class={styles.errorMessage}>
Выбраны темы из разных сообществ. Массовое изменение иерархии возможно только для тем одного сообщества.
</div>
</Show>
{/* Список выбранных тем */}
<div class={styles.selectedTopicsPreview}>
<h4>Выбранные темы ({props.selectedTopicIds.length}):</h4>
<div class={styles.topicsList}>
<For each={getSelectedTopics()}>
{(topic) => (
<div class={styles.topicPreviewItem}>
<span class={styles.topicTitle}>{topic.title}</span>
<span class={styles.topicId}>#{topic.id}</span>
<Show when={topic.parent_ids && topic.parent_ids.length > 0}>
<div class={styles.currentPath}>
Текущий путь: {getTopicPath(topic.id)}
</div>
</Show>
</div>
)}
</For>
</div>
</div>
{/* Выбор действия */}
<div class={styles.actionSelection}>
<h4>Выберите действие:</h4>
<div class={styles.actionOptions}>
<div class={styles.actionOption}>
<input
type="radio"
id="action-set"
name="action"
checked={actionType() === 'set'}
onChange={() => setActionType('set')}
/>
<label for="action-set" class={styles.actionLabel}>
<strong>Установить нового родителя</strong>
<div class={styles.actionDescription}>
Переместить все выбранные темы под одного родителя
</div>
</label>
</div>
<div class={styles.actionOption}>
<input
type="radio"
id="action-root"
name="action"
checked={actionType() === 'makeRoot'}
onChange={() => setActionType('makeRoot')}
/>
<label for="action-root" class={styles.actionLabel}>
<strong>🏠 Сделать корневыми</strong>
<div class={styles.actionDescription}>
Переместить все выбранные темы на верхний уровень
</div>
</label>
</div>
</div>
</div>
{/* Выбор родителя */}
<Show when={actionType() === 'set'}>
<div class={styles.parentSelection}>
<h4>Выбор родительской темы:</h4>
<div class={styles.searchSection}>
<input
type="text"
value={searchQuery()}
onInput={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск родительской темы..."
class={styles.searchInput}
/>
</div>
<div class={styles.parentsList}>
<For each={getAvailableParents()}>
{(topic) => (
<div class={styles.parentOption}>
<input
type="radio"
id={`bulk-parent-${topic.id}`}
name="bulk-parent"
checked={newParentId() === topic.id}
onChange={() => setNewParentId(topic.id)}
/>
<label for={`bulk-parent-${topic.id}`} class={styles.parentOptionLabel}>
<strong>{topic.title}</strong>
<div class={styles.parentDescription}>
<span class={styles.topicId}>#{topic.id}</span>
<span class={styles.topicSlug}>{topic.slug}</span>
</div>
<Show when={topic.parent_ids && topic.parent_ids.length > 0}>
<div class={styles.parentPath}>
Текущий путь: {getTopicPath(topic.id)}
</div>
</Show>
</label>
</div>
)}
</For>
</div>
<Show when={getAvailableParents().length === 0}>
<div class={styles.noResults}>
{searchQuery()
? `Нет доступных тем для поиска "${searchQuery()}"`
: 'Нет доступных родительских тем'
}
</div>
</Show>
</div>
</Show>
{/* Предварительный просмотр изменений */}
<Show when={actionType() === 'makeRoot' || (actionType() === 'set' && newParentId())}>
<div class={styles.previewSection}>
<h4>Предварительный просмотр:</h4>
<div class={styles.previewChanges}>
<For each={getSelectedTopics()}>
{(topic) => (
<div class={styles.previewItem}>
<strong>{topic.title}</strong>
<div class={styles.previewChange}>
<span class={styles.beforeState}>
Было: {topic.parent_ids?.length ? getTopicPath(topic.id) : 'Корневая тема'}
</span>
<span class={styles.arrow}></span>
<span class={styles.afterState}>
Станет: {
actionType() === 'makeRoot'
? 'Корневая тема'
: newParentId() ? `${getTopicPath(newParentId()!)}${topic.title}` : ''
}
</span>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={!!validateAction() || getTopicsByCommunity().size > 1}
>
Применить к {props.selectedTopicIds.length} темам
</Button>
</div>
</div>
</Modal>
)
}
export default TopicBulkParentModal

View File

@@ -0,0 +1,458 @@
import { Component, createSignal, For, Show } from 'solid-js'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
// Типы для топиков
interface Topic {
id: number
title: string
slug: string
parent_ids?: number[]
children?: Topic[]
level?: number
community: number
}
interface TopicHierarchyModalProps {
isOpen: boolean
onClose: () => void
topics: Topic[]
onSave: (hierarchyChanges: HierarchyChange[]) => void
onError: (error: string) => void
}
interface HierarchyChange {
topicId: number
newParentIds: number[]
oldParentIds: number[]
}
const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
const [localTopics, setLocalTopics] = createSignal<Topic[]>([])
const [changes, setChanges] = createSignal<HierarchyChange[]>([])
const [expandedNodes, setExpandedNodes] = createSignal<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = createSignal('')
const [selectedForMove, setSelectedForMove] = createSignal<number | null>(null)
// Инициализация локального состояния
const initializeTopics = () => {
const hierarchicalTopics = buildHierarchy(props.topics)
setLocalTopics(hierarchicalTopics)
setChanges([])
setSearchQuery('')
setSelectedForMove(null)
// Раскрываем все узлы по умолчанию
const allIds = new Set(props.topics.map(t => t.id))
setExpandedNodes(allIds)
}
// Построение иерархической структуры
const buildHierarchy = (flatTopics: Topic[]): Topic[] => {
const topicMap = new Map<number, Topic>()
const rootTopics: Topic[] = []
// Создаем карту всех топиков
flatTopics.forEach((topic) => {
topicMap.set(topic.id, { ...topic, children: [], level: 0 })
})
// Строим иерархию
flatTopics.forEach((topic) => {
const currentTopic = topicMap.get(topic.id)!
if (!topic.parent_ids || topic.parent_ids.length === 0) {
rootTopics.push(currentTopic)
} else {
const parentId = topic.parent_ids[topic.parent_ids.length - 1]
const parent = topicMap.get(parentId)
if (parent) {
currentTopic.level = (parent.level || 0) + 1
parent.children!.push(currentTopic)
} else {
rootTopics.push(currentTopic)
}
}
})
return rootTopics
}
// Функция удалена - используем кликабельный интерфейс вместо drag & drop
// Проверка, является ли топик потомком другого
const isDescendant = (parentId: number, childId: number): boolean => {
const checkChildren = (topics: Topic[]): boolean => {
for (const topic of topics) {
if (topic.id === childId) return true
if (topic.children && checkChildren(topic.children)) return true
}
return false
}
const parentTopic = findTopicById(parentId)
return parentTopic ? checkChildren(parentTopic.children || []) : false
}
// Поиск топика по ID
const findTopicById = (id: number): Topic | null => {
const search = (topics: Topic[]): Topic | null => {
for (const topic of topics) {
if (topic.id === id) return topic
if (topic.children) {
const found = search(topic.children)
if (found) return found
}
}
return null
}
return search(localTopics())
}
// Обновление родителя топика
const updateTopicParent = (topicId: number, newParentIds: number[]) => {
const flatTopics = flattenTopics(localTopics())
const updatedTopics = flatTopics.map(topic =>
topic.id === topicId
? { ...topic, parent_ids: newParentIds }
: topic
)
const newHierarchy = buildHierarchy(updatedTopics)
setLocalTopics(newHierarchy)
}
// Преобразование дерева в плоский список
const flattenTopics = (topics: Topic[]): Topic[] => {
const result: Topic[] = []
const flatten = (topicList: Topic[]) => {
topicList.forEach(topic => {
result.push(topic)
if (topic.children) {
flatten(topic.children)
}
})
}
flatten(topics)
return result
}
// Переключение раскрытия узла
const toggleExpanded = (topicId: number) => {
const expanded = expandedNodes()
if (expanded.has(topicId)) {
expanded.delete(topicId)
} else {
expanded.add(topicId)
}
setExpandedNodes(new Set(expanded))
}
// Поиск темы по названию
const findTopicByTitle = (title: string): Topic | null => {
const query = title.toLowerCase().trim()
if (!query) return null
const search = (topics: Topic[]): Topic | null => {
for (const topic of topics) {
if (topic.title.toLowerCase().includes(query)) {
return topic
}
if (topic.children) {
const found = search(topic.children)
if (found) return found
}
}
return null
}
return search(localTopics())
}
// Выбор темы для перемещения
const selectTopicForMove = (topicId: number) => {
setSelectedForMove(topicId)
const topic = findTopicById(topicId)
if (topic) {
props.onError(`Выбрана тема "${topic.title}" для перемещения. Теперь нажмите на новую родительскую тему или используйте "Сделать корневой".`)
}
}
// Перемещение выбранной темы к новому родителю
const moveSelectedTopic = (newParentId: number | null) => {
const selectedId = selectedForMove()
if (!selectedId) return
const selectedTopic = findTopicById(selectedId)
if (!selectedTopic) return
// Проверяем циклы
if (newParentId && isDescendant(newParentId, selectedId)) {
props.onError('Нельзя переместить тему в своего потомка')
return
}
let newParentIds: number[] = []
if (newParentId) {
const newParent = findTopicById(newParentId)
if (newParent) {
newParentIds = [...(newParent.parent_ids || []), newParentId]
}
}
// Обновляем локальное состояние
updateTopicParent(selectedId, newParentIds)
// Добавляем в список изменений
setChanges(prev => [
...prev.filter(c => c.topicId !== selectedId),
{
topicId: selectedId,
newParentIds,
oldParentIds: selectedTopic.parent_ids || []
}
])
setSelectedForMove(null)
}
// Раскрытие пути до определенной темы
const expandPathToTopic = (topicId: number) => {
const topic = findTopicById(topicId)
if (!topic || !topic.parent_ids) return
const expanded = expandedNodes()
// Раскрываем всех родителей
topic.parent_ids.forEach(parentId => {
expanded.add(parentId)
})
setExpandedNodes(new Set(expanded))
}
// Рендеринг дерева топиков
const renderTree = (topics: Topic[]): any => {
return (
<For each={topics}>
{(topic) => {
const isExpanded = expandedNodes().has(topic.id)
const isSelected = selectedForMove() === topic.id
const isTarget = selectedForMove() && selectedForMove() !== topic.id
const hasChildren = topic.children && topic.children.length > 0
return (
<div class={styles.treeNode}>
<div
class={`${styles.treeItem} ${isSelected ? styles.selectedForMove : ''} ${isTarget ? styles.moveTarget : ''}`}
onClick={() => {
if (selectedForMove() && selectedForMove() !== topic.id) {
// Если уже выбрана тема для перемещения, делаем текущую тему родителем
moveSelectedTopic(topic.id)
} else {
// Иначе выбираем эту тему для перемещения
selectTopicForMove(topic.id)
}
}}
style={{
'padding-left': `${(topic.level || 0) * 20}px`,
'cursor': 'pointer',
'border': isSelected ? '2px solid #007bff' : isTarget ? '2px dashed #28a745' : '1px solid transparent',
'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent'
}}
>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<Show when={hasChildren}>
<button
onClick={(e) => {
e.stopPropagation()
toggleExpanded(topic.id)
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
'font-size': '12px'
}}
>
{isExpanded ? '▼' : '▶'}
</button>
</Show>
<Show when={!hasChildren}>
<span style={{ width: '12px' }}></span>
</Show>
<Show when={isSelected}>
<span class={styles.selectedIcon}>📦</span>
</Show>
<Show when={isTarget}>
<span class={styles.targetIcon}>📂</span>
</Show>
<Show when={!isSelected && !isTarget}>
<span class={styles.clickIcon}>#</span>
</Show>
<span class={styles.topicTitle}>{topic.title}</span>
<span class={styles.topicId}>#{topic.id}</span>
</div>
</div>
<Show when={isExpanded && hasChildren}>
<div class={styles.treeChildren}>
{renderTree(topic.children!)}
</div>
</Show>
</div>
)
}}
</For>
)
}
// Сброс корневого уровня (перетаскивание в корень)
const makeRootTopic = (topicId: number) => {
updateTopicParent(topicId, [])
const draggedTopic = findTopicById(topicId)
if (!draggedTopic) return
setChanges(prev => [
...prev.filter(c => c.topicId !== topicId),
{
topicId,
newParentIds: [],
oldParentIds: draggedTopic.parent_ids || []
}
])
}
// Сохранение изменений
const handleSave = () => {
if (changes().length === 0) {
props.onError('Нет изменений для сохранения')
return
}
props.onSave(changes())
}
// Инициализация при открытии
if (props.isOpen && localTopics().length === 0) {
initializeTopics()
}
return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
title="Управление иерархией тем"
size="large"
>
<div class={styles.hierarchyContainer}>
<div class={styles.instructions}>
<h4>Инструкции:</h4>
<ul>
<li>🔍 Найдите тему по названию или прокрутите список</li>
<li># Нажмите на тему, чтобы выбрать её для перемещения (синяя рамка)</li>
<li>📂 Нажмите на другую тему, чтобы сделать её родителем (зеленая рамка)</li>
<li>🏠 Используйте кнопку "Сделать корневой" для перемещения на верхний уровень</li>
<li>/ Раскрывайте/сворачивайте ветки дерева</li>
</ul>
</div>
<div class={styles.searchSection}>
<label class={styles.label}>Поиск темы:</label>
<input
type="text"
value={searchQuery()}
onInput={(e) => {
const query = e.target.value
setSearchQuery(query)
// Автоматически находим и подсвечиваем тему
if (query.trim()) {
const foundTopic = findTopicByTitle(query)
if (foundTopic) {
// Раскрываем путь до найденной темы
expandPathToTopic(foundTopic.id)
}
}
}}
placeholder="Введите название темы для поиска..."
class={styles.searchInput}
/>
<Show when={searchQuery() && findTopicByTitle(searchQuery())}>
<div class={styles.searchResult}>
Найдена тема: <strong>{findTopicByTitle(searchQuery())?.title}</strong>
</div>
</Show>
<Show when={searchQuery() && !findTopicByTitle(searchQuery())}>
<div class={styles.searchNoResult}>
Тема не найдена
</div>
</Show>
</div>
<div class={styles.hierarchyTree}>
{renderTree(localTopics())}
</div>
<Show when={changes().length > 0}>
<div class={styles.changesSummary}>
<h4>Планируемые изменения ({changes().length}):</h4>
<For each={changes()}>
{(change) => {
const topic = findTopicById(change.topicId)
return (
<div class={styles.changeItem}>
<strong>{topic?.title}</strong>: {
change.newParentIds.length === 0
? 'станет корневой темой'
: `переместится под тему #${change.newParentIds[change.newParentIds.length - 1]}`
}
</div>
)
}}
</For>
</div>
</Show>
<Show when={selectedForMove()}>
<div class={styles.actionZone}>
<div class={styles.selectedTopicInfo}>
<h4>Выбрана для перемещения:</h4>
<div class={styles.selectedTopicDisplay}>
📦 <strong>{findTopicById(selectedForMove()!)?.title}</strong> #{selectedForMove()}
</div>
</div>
<div class={styles.actionButtons}>
<button
class={styles.rootButton}
onClick={() => moveSelectedTopic(null)}
>
🏠 Сделать корневой темой
</button>
<button
class={styles.cancelButton}
onClick={() => setSelectedForMove(null)}
>
Отменить выбор
</button>
</div>
</div>
</Show>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={changes().length === 0}
>
Сохранить изменения ({changes().length})
</Button>
</div>
</div>
</Modal>
)
}
export default TopicHierarchyModal

View File

@@ -0,0 +1,327 @@
import { Component, createSignal, For, Show } from 'solid-js'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
// Типы для топиков
interface Topic {
id: number
title: string
slug: string
community: number
stat?: {
shouts: number
followers: number
authors: number
comments: number
}
}
interface TopicMergeModalProps {
isOpen: boolean
onClose: () => void
topics: Topic[]
onSuccess: (message: string) => void
onError: (error: string) => void
}
interface MergeStats {
followers_moved: number
publications_moved: number
drafts_moved: number
source_topics_deleted: number
}
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('')
/**
* Получает токен авторизации из localStorage или cookie
*/
const getAuthTokenFromCookie = () => {
return document.cookie
.split('; ')
.find(row => row.startsWith('auth_token='))
?.split('=')[1] || ''
}
/**
* Обработчик выбора/снятия выбора исходной темы
*/
const handleSourceTopicToggle = (topicId: number, checked: boolean) => {
if (checked) {
setSourceTopicIds(prev => [...prev, topicId])
} else {
setSourceTopicIds(prev => prev.filter(id => id !== topicId))
}
}
/**
* Проверяет можно ли выполнить слияние
*/
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)
}
/**
* Получает название сообщества по ID (заглушка)
*/
const getCommunityName = (communityId: number) => {
// Здесь можно добавить запрос к API или кеш сообществ
return `Сообщество ${communityId}`
}
/**
* Выполняет слияние топиков
*/
const handleMerge = async () => {
if (!canMerge()) {
setError('Невозможно выполнить слияние с текущими настройками')
return
}
setLoading(true)
setError('')
try {
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
query: MERGE_TOPICS_MUTATION,
variables: {
merge_input: {
target_topic_id: targetTopicId(),
source_topic_ids: sourceTopicIds(),
preserve_target_properties: preserveTarget()
}
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const mergeResult = result.data.merge_topics
if (mergeResult.error) {
throw new Error(mergeResult.error)
}
const stats = mergeResult.stats as MergeStats
const statsText = stats ?
` (перенесено ${stats.followers_moved} подписчиков, ${stats.publications_moved} публикаций, ${stats.drafts_moved} черновиков, удалено ${stats.source_topics_deleted} тем)` : ''
props.onSuccess(mergeResult.message + statsText)
handleClose()
} catch (error) {
const errorMessage = (error as Error).message
setError(errorMessage)
props.onError(`Ошибка слияния тем: ${errorMessage}`)
} finally {
setLoading(false)
}
}
/**
* Закрывает модалку и сбрасывает состояние
*/
const handleClose = () => {
setTargetTopicId(null)
setSourceTopicIds([])
setPreserveTarget(true)
setError('')
setLoading(false)
props.onClose()
}
/**
* Получает отфильтрованный список топиков (исключая выбранные как исходные)
*/
const getAvailableTargetTopics = () => {
const sources = sourceTopicIds()
return props.topics.filter(topic => !sources.includes(topic.id))
}
/**
* Получает отфильтрованный список топиков (исключая целевую тему)
*/
const getAvailableSourceTopics = () => {
const target = targetTopicId()
return props.topics.filter(topic => topic.id !== target)
}
return (
<Modal
isOpen={props.isOpen}
onClose={handleClose}
title="Слияние тем"
size="large"
>
<div class={styles.form}>
<div class={styles.section}>
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3>
<p class={styles.description}>
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут перенесены в эту тему.
</p>
<select
value={targetTopicId() || ''}
onChange={(e) => setTargetTopicId(e.target.value ? 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>
<div class={styles.section}>
<h3 class={styles.sectionTitle}>Выбор исходных тем для слияния</h3>
<p class={styles.description}>
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех связей.
</p>
<Show when={getAvailableSourceTopics().length > 0}>
<div class={styles.checkboxList}>
<For each={getAvailableSourceTopics()}>
{(topic) => {
const isChecked = () => sourceTopicIds().includes(topic.id)
return (
<label class={styles.checkboxItem}>
<input
type="checkbox"
checked={isChecked()}
onChange={(e) => handleSourceTopicToggle(topic.id, e.target.checked)}
disabled={loading()}
class={styles.checkbox}
/>
<div class={styles.checkboxContent}>
<div class={styles.topicTitle}>{topic.title}</div>
<div class={styles.topicInfo}>
{getCommunityName(topic.community)} ID: {topic.id}
{topic.stat && (
<span> {topic.stat.shouts} публ., {topic.stat.followers} подп.</span>
)}
</div>
</div>
</label>
)
}}
</For>
</div>
</Show>
</div>
<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>
</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()}
>
Отмена
</Button>
<Button
variant="danger"
onClick={handleMerge}
disabled={!canMerge() || loading()}
>
{loading() ? 'Выполняется слияние...' : 'Слить темы'}
</Button>
</div>
</div>
</Modal>
)
}
export default TopicMergeModal

View File

@@ -0,0 +1,215 @@
import { Component, createSignal, For, Show } from 'solid-js'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
interface Topic {
id: number
title: string
slug: string
parent_ids?: number[]
community: number
}
interface TopicParentModalProps {
isOpen: boolean
onClose: () => void
topic: Topic | null
allTopics: Topic[]
onSave: (topic: Topic) => void
onError: (error: string) => void
}
const TopicParentModal: Component<TopicParentModalProps> = (props) => {
const [selectedParentId, setSelectedParentId] = createSignal<number | null>(null)
const [searchQuery, setSearchQuery] = createSignal('')
// Получаем текущего родителя при открытии модалки
const getCurrentParentId = (): number | null => {
const topic = props.topic
if (!topic || !topic.parent_ids || topic.parent_ids.length === 0) {
return null
}
return topic.parent_ids[topic.parent_ids.length - 1]
}
// Фильтрация доступных родителей
const getAvailableParents = () => {
const currentTopic = props.topic
if (!currentTopic) return []
return props.allTopics.filter(topic => {
// Исключаем сам топик
if (topic.id === currentTopic.id) return false
// Исключаем топики из других сообществ
if (topic.community !== currentTopic.community) return false
// Исключаем дочерние топики (предотвращаем циклы)
if (isDescendant(currentTopic.id, topic.id)) return false
// Фильтр по поисковому запросу
const query = searchQuery().toLowerCase()
if (query && !topic.title.toLowerCase().includes(query)) return false
return true
})
}
// Проверка, является ли топик потомком другого
const isDescendant = (ancestorId: number, descendantId: number): boolean => {
const descendant = props.allTopics.find(t => t.id === descendantId)
if (!descendant || !descendant.parent_ids) return false
return descendant.parent_ids.includes(ancestorId)
}
// Получение пути к корню для отображения полного пути
const getTopicPath = (topicId: number): string => {
const topic = props.allTopics.find(t => t.id === topicId)
if (!topic) return ''
if (!topic.parent_ids || topic.parent_ids.length === 0) {
return topic.title
}
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
return `${parentPath}${topic.title}`
}
// Сохранение изменений
const handleSave = () => {
const currentTopic = props.topic
if (!currentTopic) return
const newParentId = selectedParentId()
let newParentIds: number[] = []
if (newParentId) {
const parentTopic = props.allTopics.find(t => t.id === newParentId)
if (parentTopic) {
// Строим полный путь от корня до нового родителя
newParentIds = [...(parentTopic.parent_ids || []), newParentId]
}
}
const updatedTopic: Topic = {
...currentTopic,
parent_ids: newParentIds
}
props.onSave(updatedTopic)
}
// Инициализация при открытии
if (props.isOpen && props.topic) {
setSelectedParentId(getCurrentParentId())
setSearchQuery('')
}
return (
<Modal
isOpen={props.isOpen}
onClose={props.onClose}
title={`Выбор родительской темы для "${props.topic?.title}"`}
>
<div class={styles.parentSelectorContainer}>
<div class={styles.searchSection}>
<label class={styles.label}>Поиск родительской темы:</label>
<input
type="text"
value={searchQuery()}
onInput={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название темы..."
class={styles.searchInput}
/>
</div>
<div class={styles.currentSelection}>
<label class={styles.label}>Текущий родитель:</label>
<div class={styles.currentParent}>
<Show
when={getCurrentParentId()}
fallback={<span class={styles.noParent}>Корневая тема</span>}
>
<span class={styles.parentPath}>
{getCurrentParentId() ? getTopicPath(getCurrentParentId()!) : ''}
</span>
</Show>
</div>
</div>
<div class={styles.parentOptions}>
<label class={styles.label}>Выберите нового родителя:</label>
{/* Опция "Сделать корневой" */}
<div class={styles.parentOption}>
<input
type="radio"
id="root-option"
name="parent"
checked={selectedParentId() === null}
onChange={() => setSelectedParentId(null)}
/>
<label for="root-option" class={styles.parentOptionLabel}>
<strong>🏠 Корневая тема</strong>
<div class={styles.parentDescription}>
Переместить на верхний уровень иерархии
</div>
</label>
</div>
{/* Доступные родители */}
<div class={styles.parentsList}>
<For each={getAvailableParents()}>
{(topic) => (
<div class={styles.parentOption}>
<input
type="radio"
id={`parent-${topic.id}`}
name="parent"
checked={selectedParentId() === topic.id}
onChange={() => setSelectedParentId(topic.id)}
/>
<label for={`parent-${topic.id}`} class={styles.parentOptionLabel}>
<strong>{topic.title}</strong>
<div class={styles.parentDescription}>
<span class={styles.topicId}>#{topic.id}</span>
<span class={styles.topicSlug}>{topic.slug}</span>
</div>
<Show when={topic.parent_ids && topic.parent_ids.length > 0}>
<div class={styles.parentPath}>
Путь: {getTopicPath(topic.id)}
</div>
</Show>
</label>
</div>
)}
</For>
</div>
<Show when={getAvailableParents().length === 0 && searchQuery()}>
<div class={styles.noResults}>
Нет тем, соответствующих поисковому запросу "{searchQuery()}"
</div>
</Show>
</div>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={props.onClose}>
Отмена
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={selectedParentId() === getCurrentParentId()}
>
Сохранить
</Button>
</div>
</div>
</Modal>
)
}
export default TopicParentModal

View File

@@ -0,0 +1,305 @@
import { Component, createSignal, For, Show } from 'solid-js'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
import { SET_TOPIC_PARENT_MUTATION } from '../graphql/mutations'
// Типы для топиков
interface Topic {
id: number
title: string
slug: string
parent_ids?: number[]
community: number
}
interface TopicSimpleParentModalProps {
isOpen: boolean
onClose: () => void
topic: Topic | null
allTopics: Topic[]
onSuccess: (message: string) => void
onError: (error: string) => void
}
const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) => {
const [selectedParentId, setSelectedParentId] = createSignal<number | null>(null)
const [loading, setLoading] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal('')
/**
* Получает токен авторизации
*/
const getAuthTokenFromCookie = () => {
return document.cookie
.split('; ')
.find(row => row.startsWith('auth_token='))
?.split('=')[1] || ''
}
/**
* Получает текущего родителя темы
*/
const getCurrentParentId = (): number | null => {
if (!props.topic?.parent_ids || props.topic.parent_ids.length === 0) {
return null
}
return props.topic.parent_ids[props.topic.parent_ids.length - 1]
}
/**
* Получает путь темы до корня
*/
const getTopicPath = (topicId: number): string => {
const topic = props.allTopics.find(t => t.id === topicId)
if (!topic) return 'Неизвестная тема'
if (!topic.parent_ids || topic.parent_ids.length === 0) {
return topic.title
}
const parentPath = getTopicPath(topic.parent_ids[topic.parent_ids.length - 1])
return `${parentPath}${topic.title}`
}
/**
* Проверяет циклические зависимости
*/
const isDescendant = (parentId: number, childId: number): boolean => {
if (parentId === childId) return true
const checkDescendants = (currentId: number): boolean => {
const descendants = props.allTopics.filter(t =>
t.parent_ids && t.parent_ids.includes(currentId)
)
for (const descendant of descendants) {
if (descendant.id === childId || checkDescendants(descendant.id)) {
return true
}
}
return false
}
return checkDescendants(parentId)
}
/**
* Получает доступных родителей (исключая потомков и темы из других сообществ)
*/
const getAvailableParents = () => {
if (!props.topic) return []
const query = searchQuery().toLowerCase()
return props.allTopics.filter(topic => {
// Исключаем саму тему
if (topic.id === props.topic!.id) return false
// Только темы из того же сообщества
if (topic.community !== props.topic!.community) return false
// Исключаем потомков (предотвращаем циклы)
if (isDescendant(topic.id, props.topic!.id)) return false
// Фильтр по поиску
if (query && !topic.title.toLowerCase().includes(query)) return false
return true
})
}
/**
* Выполняет назначение родителя
*/
const handleSetParent = async () => {
if (!props.topic) return
setLoading(true)
try {
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
},
body: JSON.stringify({
query: SET_TOPIC_PARENT_MUTATION,
variables: {
topic_id: props.topic.id,
parent_id: selectedParentId()
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const setResult = result.data.set_topic_parent
if (setResult.error) {
throw new Error(setResult.error)
}
props.onSuccess(setResult.message)
handleClose()
} catch (error) {
const errorMessage = (error as Error).message
props.onError(`Ошибка назначения родителя: ${errorMessage}`)
} finally {
setLoading(false)
}
}
/**
* Закрывает модалку и сбрасывает состояние
*/
const handleClose = () => {
setSelectedParentId(null)
setSearchQuery('')
setLoading(false)
props.onClose()
}
return (
<Modal
isOpen={props.isOpen}
onClose={handleClose}
title="Назначить родительскую тему"
size="medium"
>
<div class={styles.parentSelectorContainer}>
<Show when={props.topic}>
<div class={styles.currentSelection}>
<h4>Редактируемая тема:</h4>
<div class={styles.topicDisplay}>
<strong>{props.topic?.title}</strong> #{props.topic?.id}
</div>
<div class={styles.currentParent}>
<strong>Текущее расположение:</strong>
<div class={styles.parentPath}>
{getCurrentParentId() ?
getTopicPath(props.topic!.id) :
<span class={styles.noParent}>🏠 Корневая тема</span>
}
</div>
</div>
</div>
<div class={styles.searchSection}>
<label class={styles.label}>Поиск новой родительской темы:</label>
<input
type="text"
value={searchQuery()}
onInput={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название темы..."
class={styles.searchInput}
disabled={loading()}
/>
</div>
<div class={styles.parentOptions}>
<h4>Выберите новую родительскую тему:</h4>
{/* Опция корневой темы */}
<div class={styles.parentsList}>
<label class={styles.parentOption}>
<input
type="radio"
name="parentSelection"
checked={selectedParentId() === null}
onChange={() => setSelectedParentId(null)}
disabled={loading()}
/>
<div class={styles.parentOptionLabel}>
<div class={styles.topicTitle}>
🏠 Сделать корневой темой
</div>
<div class={styles.parentDescription}>
Тема будет перемещена на верхний уровень иерархии
</div>
</div>
</label>
</div>
{/* Список доступных родителей */}
<div class={styles.parentsList}>
<Show when={getAvailableParents().length > 0}>
<For each={getAvailableParents()}>
{(topic) => (
<label class={styles.parentOption}>
<input
type="radio"
name="parentSelection"
checked={selectedParentId() === topic.id}
onChange={() => setSelectedParentId(topic.id)}
disabled={loading()}
/>
<div class={styles.parentOptionLabel}>
<div class={styles.topicTitle}>
{topic.title}
</div>
<div class={styles.parentDescription}>
<span class={styles.topicId}>ID: {topic.id}</span>
<span class={styles.topicSlug}> {topic.slug}</span>
<br />
<strong>Путь:</strong> {getTopicPath(topic.id)}
</div>
</div>
</label>
)}
</For>
</Show>
<Show when={getAvailableParents().length === 0}>
<div class={styles.noResults}>
{searchQuery() ?
'Не найдено подходящих тем по запросу' :
'Нет доступных родительских тем'
}
</div>
</Show>
</div>
</div>
<Show when={selectedParentId() !== null}>
<div class={styles.preview}>
<h4>Предварительный просмотр:</h4>
<div class={styles.previewPath}>
<strong>Новое расположение:</strong><br />
{getTopicPath(selectedParentId()!)} <strong>{props.topic?.title}</strong>
</div>
</div>
</Show>
<div class={styles.modalActions}>
<Button
variant="secondary"
onClick={handleClose}
disabled={loading()}
>
Отмена
</Button>
<Button
variant="primary"
onClick={handleSetParent}
disabled={loading() || (selectedParentId() === getCurrentParentId())}
>
{loading() ? 'Назначение...' : 'Назначить родителя'}
</Button>
</div>
</Show>
</div>
</Modal>
)
}
export default TopicSimpleParentModal