panel-improves

This commit is contained in:
Untone 2025-07-01 09:32:22 +03:00
parent 547c934302
commit 27a358a41f
10 changed files with 284 additions and 310 deletions

View File

@ -14,12 +14,11 @@
- **Компактный дизайн**: Уменьшены отступы (padding) для экономии места - **Компактный дизайн**: Уменьшены отступы (padding) для экономии места
- **Улучшенная синхронизация скролла**: Номера строк синхронизируются со скроллом основного контента - **Улучшенная синхронизация скролла**: Номера строк синхронизируются со скроллом основного контента
- **ИСПРАВЛЕНО**: Исправлена проблема с курсором в режиме редактирования - курсор теперь корректно перемещается при вводе текста и сохраняет позицию при обновлении содержимого - **ИСПРАВЛЕНО**: Исправлена проблема с курсором в режиме редактирования - курсор теперь корректно перемещается при вводе текста и сохраняет позицию при обновлении содержимого
- **ИСПРАВЛЕНО**: Номера строк теперь правильно синхронизируются с содержимым - они прокручиваются вместе с текстом и показывают реальные номера строк документа - Номера строк теперь правильно синхронизируются с содержимым - они прокручиваются вместе с текстом и показывают реальные номера строк документа
- **УЛУЧШЕНО**: Увеличена максимальная высота модальных окон с содержимым публикаций с 70vh до 85vh для более комфортного редактирования - Увеличена высота модальных окон
- **ИСПРАВЛЕНО**: Убраны жесткие ограничения высоты в CSS (`min-height: 500px` в `.editableCodeContainer` и `min-height: 450px` в `.editorArea`) - теперь размер полностью контролируется параметром `maxHeight` - **УЛУЧШЕНО**: Уменьшена ширина области номеров строк с 50px до 24px для максимальной экономии места
- **УЛУЧШЕНО**: Редактор кода теперь использует точную высоту `height: 85vh` вместо ограничений `min-height/max-height` для лучшего контроля размеров - **ОПТИМИЗИРОВАНО**: Размер шрифта номеров строк уменьшен до 9px, padding уменьшен до 2px для компактности
- **ИСПРАВЛЕНО**: Модальное окно размера "large" теперь действительно занимает 85% высоты экрана (`height: 85vh, max-height: 85vh`) - **УЛУЧШЕНО**: Содержимое сдвинуто ближе к левому краю (left: 24px), уменьшен padding с 12px до 8px для лучшего использования пространства
- **УЛУЧШЕНО**: Содержимое модального окна использует `flex: 1` для заполнения всей доступной площади, убран padding для максимального использования пространства
- **Техническая архитектура**: - **Техническая архитектура**:
- Функция `formatHtmlContent()` для автоматического форматирования HTML разметки - Функция `formatHtmlContent()` для автоматического форматирования HTML разметки
- Функция `generateLineNumbers()` для генерации номеров строк - Функция `generateLineNumbers()` для генерации номеров строк
@ -28,6 +27,7 @@
- Улучшенная обработка различных типов контента (HTML/markup vs обычный текст) - Улучшенная обработка различных типов контента (HTML/markup vs обычный текст)
- Правильная работа с Selection API для сохранения позиции курсора в contentEditable элементах - Правильная работа с Selection API для сохранения позиции курсора в contentEditable элементах
- Синхронизация содержимого редактируемой области без потери фокуса и позиции курсора - Синхронизация содержимого редактируемой области без потери фокуса и позиции курсора
- **РЕФАКТОРИНГ СТИЛЕЙ**: Все inline стили перенесены в CSS модули для лучшей поддерживаемости кода
### Исправления авторизации ### Исправления авторизации

View File

@ -1,7 +1,7 @@
import { Component, createSignal, For, Show } from 'solid-js' import { Component, createSignal, For, Show } from 'solid-js'
import styles from '../styles/Form.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
interface Topic { interface Topic {
id: number id: number
@ -33,21 +33,19 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
// Получаем выбранные топики // Получаем выбранные топики
const getSelectedTopics = () => { const getSelectedTopics = () => {
return props.allTopics.filter(topic => return props.allTopics.filter((topic) => props.selectedTopicIds.includes(topic.id))
props.selectedTopicIds.includes(topic.id)
)
} }
// Фильтрация доступных родителей // Фильтрация доступных родителей
const getAvailableParents = () => { const getAvailableParents = () => {
const selectedIds = new Set(props.selectedTopicIds) const selectedIds = new Set(props.selectedTopicIds)
return props.allTopics.filter(topic => { return props.allTopics.filter((topic) => {
// Исключаем выбранные топики // Исключаем выбранные топики
if (selectedIds.has(topic.id)) return false if (selectedIds.has(topic.id)) return false
// Исключаем топики, которые являются детьми выбранных // Исключаем топики, которые являются детьми выбранных
const isChildOfSelected = props.selectedTopicIds.some(selectedId => const isChildOfSelected = props.selectedTopicIds.some((selectedId) =>
isDescendant(selectedId, topic.id) isDescendant(selectedId, topic.id)
) )
if (isChildOfSelected) return false if (isChildOfSelected) return false
@ -62,7 +60,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
// Проверка, является ли топик потомком другого // Проверка, является ли топик потомком другого
const isDescendant = (ancestorId: number, descendantId: number): boolean => { const isDescendant = (ancestorId: number, descendantId: number): boolean => {
const descendant = props.allTopics.find(t => t.id === descendantId) const descendant = props.allTopics.find((t) => t.id === descendantId)
if (!descendant || !descendant.parent_ids) return false if (!descendant || !descendant.parent_ids) return false
return descendant.parent_ids.includes(ancestorId) return descendant.parent_ids.includes(ancestorId)
@ -70,7 +68,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
// Получение пути к корню // Получение пути к корню
const getTopicPath = (topicId: number): string => { const getTopicPath = (topicId: number): string => {
const topic = props.allTopics.find(t => t.id === topicId) const topic = props.allTopics.find((t) => t.id === topicId)
if (!topic) return '' if (!topic) return ''
if (!topic.parent_ids || topic.parent_ids.length === 0) { if (!topic.parent_ids || topic.parent_ids.length === 0) {
@ -86,7 +84,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
const selectedTopics = getSelectedTopics() const selectedTopics = getSelectedTopics()
const communities = new Map<number, Topic[]>() const communities = new Map<number, Topic[]>()
selectedTopics.forEach(topic => { selectedTopics.forEach((topic) => {
if (!communities.has(topic.community)) { if (!communities.has(topic.community)) {
communities.set(topic.community, []) communities.set(topic.community, [])
} }
@ -108,7 +106,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
return 'Выберите родительскую тему' return 'Выберите родительскую тему'
} }
const selectedParent = props.allTopics.find(t => t.id === newParentId()) const selectedParent = props.allTopics.find((t) => t.id === newParentId())
if (selectedParent) { if (selectedParent) {
const selectedCommunity = Array.from(communities.keys())[0] const selectedCommunity = Array.from(communities.keys())[0]
if (selectedParent.community !== selectedCommunity) { if (selectedParent.community !== selectedCommunity) {
@ -130,11 +128,11 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
const changes: BulkParentChange[] = [] const changes: BulkParentChange[] = []
const selectedTopics = getSelectedTopics() const selectedTopics = getSelectedTopics()
selectedTopics.forEach(topic => { selectedTopics.forEach((topic) => {
let newParentIds: number[] = [] let newParentIds: number[] = []
if (actionType() === 'set' && newParentId()) { if (actionType() === 'set' && newParentId()) {
const parentTopic = props.allTopics.find(t => t.id === newParentId()) const parentTopic = props.allTopics.find((t) => t.id === newParentId())
if (parentTopic) { if (parentTopic) {
newParentIds = [...(parentTopic.parent_ids || []), newParentId()!] newParentIds = [...(parentTopic.parent_ids || []), newParentId()!]
} }
@ -161,7 +159,8 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
{/* Проверка совместимости */} {/* Проверка совместимости */}
<Show when={getTopicsByCommunity().size > 1}> <Show when={getTopicsByCommunity().size > 1}>
<div class={styles.errorMessage}> <div class={styles.errorMessage}>
Выбраны темы из разных сообществ. Массовое изменение иерархии возможно только для тем одного сообщества. Выбраны темы из разных сообществ. Массовое изменение иерархии возможно только для тем одного
сообщества.
</div> </div>
</Show> </Show>
@ -175,9 +174,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
<span class={styles.topicTitle}>{topic.title}</span> <span class={styles.topicTitle}>{topic.title}</span>
<span class={styles.topicId}>#{topic.id}</span> <span class={styles.topicId}>#{topic.id}</span>
<Show when={topic.parent_ids && topic.parent_ids.length > 0}> <Show when={topic.parent_ids && topic.parent_ids.length > 0}>
<div class={styles.currentPath}> <div class={styles.currentPath}>Текущий путь: {getTopicPath(topic.id)}</div>
Текущий путь: {getTopicPath(topic.id)}
</div>
</Show> </Show>
</div> </div>
)} )}
@ -256,9 +253,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
<span class={styles.topicSlug}>{topic.slug}</span> <span class={styles.topicSlug}>{topic.slug}</span>
</div> </div>
<Show when={topic.parent_ids && topic.parent_ids.length > 0}> <Show when={topic.parent_ids && topic.parent_ids.length > 0}>
<div class={styles.parentPath}> <div class={styles.parentPath}>Текущий путь: {getTopicPath(topic.id)}</div>
Текущий путь: {getTopicPath(topic.id)}
</div>
</Show> </Show>
</label> </label>
</div> </div>
@ -270,8 +265,7 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
<div class={styles.noResults}> <div class={styles.noResults}>
{searchQuery() {searchQuery()
? `Нет доступных тем для поиска "${searchQuery()}"` ? `Нет доступных тем для поиска "${searchQuery()}"`
: 'Нет доступных родительских тем' : 'Нет доступных родительских тем'}
}
</div> </div>
</Show> </Show>
</div> </div>
@ -292,11 +286,12 @@ const TopicBulkParentModal: Component<TopicBulkParentModalProps> = (props) => {
</span> </span>
<span class={styles.arrow}></span> <span class={styles.arrow}></span>
<span class={styles.afterState}> <span class={styles.afterState}>
Станет: { Станет:{' '}
actionType() === 'makeRoot' {actionType() === 'makeRoot'
? 'Корневая тема' ? 'Корневая тема'
: newParentId() ? `${getTopicPath(newParentId()!)}${topic.title}` : '' : newParentId()
} ? `${getTopicPath(newParentId()!)}${topic.title}`
: ''}
</span> </span>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
import { Component, createSignal, For, Show } from 'solid-js' import { Component, createSignal, For, JSX, Show } from 'solid-js'
import styles from '../styles/Form.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
// Типы для топиков // Типы для топиков
interface Topic { interface Topic {
@ -28,7 +28,7 @@ interface HierarchyChange {
oldParentIds: number[] oldParentIds: number[]
} }
const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => { const TopicHierarchyModal = (props: TopicHierarchyModalProps) => {
const [localTopics, setLocalTopics] = createSignal<Topic[]>([]) const [localTopics, setLocalTopics] = createSignal<Topic[]>([])
const [changes, setChanges] = createSignal<HierarchyChange[]>([]) const [changes, setChanges] = createSignal<HierarchyChange[]>([])
const [expandedNodes, setExpandedNodes] = createSignal<Set<number>>(new Set()) const [expandedNodes, setExpandedNodes] = createSignal<Set<number>>(new Set())
@ -43,7 +43,7 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
setSearchQuery('') setSearchQuery('')
setSelectedForMove(null) setSelectedForMove(null)
// Раскрываем все узлы по умолчанию // Раскрываем все узлы по умолчанию
const allIds = new Set(props.topics.map(t => t.id)) const allIds = new Set(props.topics.map((t) => t.id))
setExpandedNodes(allIds) setExpandedNodes(allIds)
} }
@ -112,10 +112,8 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
// Обновление родителя топика // Обновление родителя топика
const updateTopicParent = (topicId: number, newParentIds: number[]) => { const updateTopicParent = (topicId: number, newParentIds: number[]) => {
const flatTopics = flattenTopics(localTopics()) const flatTopics = flattenTopics(localTopics())
const updatedTopics = flatTopics.map(topic => const updatedTopics = flatTopics.map((topic) =>
topic.id === topicId topic.id === topicId ? { ...topic, parent_ids: newParentIds } : topic
? { ...topic, parent_ids: newParentIds }
: topic
) )
const newHierarchy = buildHierarchy(updatedTopics) const newHierarchy = buildHierarchy(updatedTopics)
setLocalTopics(newHierarchy) setLocalTopics(newHierarchy)
@ -125,7 +123,7 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
const flattenTopics = (topics: Topic[]): Topic[] => { const flattenTopics = (topics: Topic[]): Topic[] => {
const result: Topic[] = [] const result: Topic[] = []
const flatten = (topicList: Topic[]) => { const flatten = (topicList: Topic[]) => {
topicList.forEach(topic => { topicList.forEach((topic) => {
result.push(topic) result.push(topic)
if (topic.children) { if (topic.children) {
flatten(topic.children) flatten(topic.children)
@ -173,7 +171,9 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
setSelectedForMove(topicId) setSelectedForMove(topicId)
const topic = findTopicById(topicId) const topic = findTopicById(topicId)
if (topic) { if (topic) {
props.onError(`Выбрана тема "${topic.title}" для перемещения. Теперь нажмите на новую родительскую тему или используйте "Сделать корневой".`) props.onError(
`Выбрана тема "${topic.title}" для перемещения. Теперь нажмите на новую родительскую тему или используйте "Сделать корневой".`
)
} }
} }
@ -203,8 +203,8 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
updateTopicParent(selectedId, newParentIds) updateTopicParent(selectedId, newParentIds)
// Добавляем в список изменений // Добавляем в список изменений
setChanges(prev => [ setChanges((prev) => [
...prev.filter(c => c.topicId !== selectedId), ...prev.filter((c) => c.topicId !== selectedId),
{ {
topicId: selectedId, topicId: selectedId,
newParentIds, newParentIds,
@ -222,14 +222,14 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
const expanded = expandedNodes() const expanded = expandedNodes()
// Раскрываем всех родителей // Раскрываем всех родителей
topic.parent_ids.forEach(parentId => { topic.parent_ids.forEach((parentId) => {
expanded.add(parentId) expanded.add(parentId)
}) })
setExpandedNodes(new Set(expanded)) setExpandedNodes(new Set(expanded))
} }
// Рендеринг дерева топиков // Рендеринг дерева топиков
const renderTree = (topics: Topic[]): any => { const renderTree = (topics: Topic[]): JSX.Element => {
return ( return (
<For each={topics}> <For each={topics}>
{(topic) => { {(topic) => {
@ -253,8 +253,12 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
}} }}
style={{ style={{
'padding-left': `${(topic.level || 0) * 20}px`, 'padding-left': `${(topic.level || 0) * 20}px`,
'cursor': 'pointer', cursor: 'pointer',
'border': isSelected ? '2px solid #007bff' : isTarget ? '2px dashed #28a745' : '1px solid transparent', border: isSelected
? '2px solid #007bff'
: isTarget
? '2px dashed #28a745'
: '1px solid transparent',
'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent' 'background-color': isSelected ? '#e3f2fd' : isTarget ? '#d4edda' : 'transparent'
}} }}
> >
@ -276,7 +280,7 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
</button> </button>
</Show> </Show>
<Show when={!hasChildren}> <Show when={!hasChildren}>
<span style={{ width: '12px' }}></span> <span style={{ width: '12px' }} />
</Show> </Show>
<Show when={isSelected}> <Show when={isSelected}>
@ -295,9 +299,7 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
</div> </div>
<Show when={isExpanded && hasChildren}> <Show when={isExpanded && hasChildren}>
<div class={styles.treeChildren}> <div class={styles.treeChildren}>{renderTree(topic.children!)}</div>
{renderTree(topic.children!)}
</div>
</Show> </Show>
</div> </div>
) )
@ -306,23 +308,6 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
) )
} }
// Сброс корневого уровня (перетаскивание в корень)
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 = () => { const handleSave = () => {
if (changes().length === 0) { if (changes().length === 0) {
@ -338,12 +323,7 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
} }
return ( return (
<Modal <Modal isOpen={props.isOpen} onClose={props.onClose} title="Управление иерархией тем" size="large">
isOpen={props.isOpen}
onClose={props.onClose}
title="Управление иерархией тем"
size="large"
>
<div class={styles.hierarchyContainer}> <div class={styles.hierarchyContainer}>
<div class={styles.instructions}> <div class={styles.instructions}>
<h4>Инструкции:</h4> <h4>Инструкции:</h4>
@ -382,15 +362,11 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={searchQuery() && !findTopicByTitle(searchQuery())}> <Show when={searchQuery() && !findTopicByTitle(searchQuery())}>
<div class={styles.searchNoResult}> <div class={styles.searchNoResult}> Тема не найдена</div>
Тема не найдена
</div>
</Show> </Show>
</div> </div>
<div class={styles.hierarchyTree}> <div class={styles.hierarchyTree}>{renderTree(localTopics())}</div>
{renderTree(localTopics())}
</div>
<Show when={changes().length > 0}> <Show when={changes().length > 0}>
<div class={styles.changesSummary}> <div class={styles.changesSummary}>
@ -400,11 +376,10 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
const topic = findTopicById(change.topicId) const topic = findTopicById(change.topicId)
return ( return (
<div class={styles.changeItem}> <div class={styles.changeItem}>
<strong>{topic?.title}</strong>: { <strong>{topic?.title}</strong>:{' '}
change.newParentIds.length === 0 {change.newParentIds.length === 0
? 'станет корневой темой' ? 'станет корневой темой'
: `переместится под тему #${change.newParentIds[change.newParentIds.length - 1]}` : `переместится под тему #${change.newParentIds[change.newParentIds.length - 1]}`}
}
</div> </div>
) )
}} }}
@ -422,16 +397,10 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
</div> </div>
<div class={styles.actionButtons}> <div class={styles.actionButtons}>
<button <button class={styles.rootButton} onClick={() => moveSelectedTopic(null)}>
class={styles.rootButton}
onClick={() => moveSelectedTopic(null)}
>
🏠 Сделать корневой темой 🏠 Сделать корневой темой
</button> </button>
<button <button class={styles.cancelButton} onClick={() => setSelectedForMove(null)}>
class={styles.cancelButton}
onClick={() => setSelectedForMove(null)}
>
Отменить выбор Отменить выбор
</button> </button>
</div> </div>
@ -442,11 +411,7 @@ const TopicHierarchyModal: Component<TopicHierarchyModalProps> = (props) => {
<Button variant="secondary" onClick={props.onClose}> <Button variant="secondary" onClick={props.onClose}>
Отмена Отмена
</Button> </Button>
<Button <Button variant="primary" onClick={handleSave} disabled={changes().length === 0}>
variant="primary"
onClick={handleSave}
disabled={changes().length === 0}
>
Сохранить изменения ({changes().length}) Сохранить изменения ({changes().length})
</Button> </Button>
</div> </div>

View File

@ -1,8 +1,8 @@
import { Component, createSignal, For, Show } from 'solid-js' import { Component, createSignal, For, Show } from 'solid-js'
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
import styles from '../styles/Form.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
import { MERGE_TOPICS_MUTATION } from '../graphql/mutations'
// Типы для топиков // Типы для топиков
interface Topic { interface Topic {
@ -44,10 +44,12 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
* Получает токен авторизации из localStorage или cookie * Получает токен авторизации из localStorage или cookie
*/ */
const getAuthTokenFromCookie = () => { const getAuthTokenFromCookie = () => {
return document.cookie return (
.split('; ') document.cookie
.find(row => row.startsWith('auth_token=')) .split('; ')
?.split('=')[1] || '' .find((row) => row.startsWith('auth_token='))
?.split('=')[1] || ''
)
} }
/** /**
@ -55,9 +57,9 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
*/ */
const handleSourceTopicToggle = (topicId: number, checked: boolean) => { const handleSourceTopicToggle = (topicId: number, checked: boolean) => {
if (checked) { if (checked) {
setSourceTopicIds(prev => [...prev, topicId]) setSourceTopicIds((prev) => [...prev, topicId])
} else { } else {
setSourceTopicIds(prev => prev.filter(id => id !== topicId)) setSourceTopicIds((prev) => prev.filter((id) => id !== topicId))
} }
} }
@ -78,13 +80,13 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
} }
// Проверяем что все темы принадлежат одному сообществу // Проверяем что все темы принадлежат одному сообществу
const targetTopic = props.topics.find(t => t.id === target) const targetTopic = props.topics.find((t) => t.id === target)
if (!targetTopic) return false if (!targetTopic) return false
const targetCommunity = targetTopic.community const targetCommunity = targetTopic.community
const sourcesTopics = props.topics.filter(t => sources.includes(t.id)) const sourcesTopics = props.topics.filter((t) => sources.includes(t.id))
return sourcesTopics.every(topic => topic.community === targetCommunity) return sourcesTopics.every((topic) => topic.community === targetCommunity)
} }
/** /**
@ -141,12 +143,12 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
} }
const stats = mergeResult.stats as MergeStats const stats = mergeResult.stats as MergeStats
const statsText = stats ? const statsText = stats
` (перенесено ${stats.followers_moved} подписчиков, ${stats.publications_moved} публикаций, ${stats.drafts_moved} черновиков, удалено ${stats.source_topics_deleted} тем)` : '' ? ` (перенесено ${stats.followers_moved} подписчиков, ${stats.publications_moved} публикаций, ${stats.drafts_moved} черновиков, удалено ${stats.source_topics_deleted} тем)`
: ''
props.onSuccess(mergeResult.message + statsText) props.onSuccess(mergeResult.message + statsText)
handleClose() handleClose()
} catch (error) { } catch (error) {
const errorMessage = (error as Error).message const errorMessage = (error as Error).message
setError(errorMessage) setError(errorMessage)
@ -173,7 +175,7 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
*/ */
const getAvailableTargetTopics = () => { const getAvailableTargetTopics = () => {
const sources = sourceTopicIds() const sources = sourceTopicIds()
return props.topics.filter(topic => !sources.includes(topic.id)) return props.topics.filter((topic) => !sources.includes(topic.id))
} }
/** /**
@ -181,26 +183,22 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
*/ */
const getAvailableSourceTopics = () => { const getAvailableSourceTopics = () => {
const target = targetTopicId() const target = targetTopicId()
return props.topics.filter(topic => topic.id !== target) return props.topics.filter((topic) => topic.id !== target)
} }
return ( return (
<Modal <Modal isOpen={props.isOpen} onClose={handleClose} title="Слияние тем" size="large">
isOpen={props.isOpen}
onClose={handleClose}
title="Слияние тем"
size="large"
>
<div class={styles.form}> <div class={styles.form}>
<div class={styles.section}> <div class={styles.section}>
<h3 class={styles.sectionTitle}>Выбор целевой темы</h3> <h3 class={styles.sectionTitle}>Выбор целевой темы</h3>
<p class={styles.description}> <p class={styles.description}>
Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут перенесены в эту тему. Выберите тему, в которую будут слиты остальные темы. Все подписчики и публикации будут
перенесены в эту тему.
</p> </p>
<select <select
value={targetTopicId() || ''} value={targetTopicId() || ''}
onChange={(e) => setTargetTopicId(e.target.value ? parseInt(e.target.value) : null)} onChange={(e) => setTargetTopicId(e.target.value ? Number.parseInt(e.target.value) : null)}
class={styles.select} class={styles.select}
disabled={loading()} disabled={loading()}
> >
@ -219,7 +217,8 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
<div class={styles.section}> <div class={styles.section}>
<h3 class={styles.sectionTitle}>Выбор исходных тем для слияния</h3> <h3 class={styles.sectionTitle}>Выбор исходных тем для слияния</h3>
<p class={styles.description}> <p class={styles.description}>
Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех связей. Выберите темы, которые будут слиты в целевую тему. Эти темы будут удалены после переноса всех
связей.
</p> </p>
<Show when={getAvailableSourceTopics().length > 0}> <Show when={getAvailableSourceTopics().length > 0}>
@ -242,7 +241,10 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
<div class={styles.topicInfo}> <div class={styles.topicInfo}>
{getCommunityName(topic.community)} ID: {topic.id} {getCommunityName(topic.community)} ID: {topic.id}
{topic.stat && ( {topic.stat && (
<span> {topic.stat.shouts} публ., {topic.stat.followers} подп.</span> <span>
{' '}
{topic.stat.shouts} публ., {topic.stat.followers} подп.
</span>
)} )}
</div> </div>
</div> </div>
@ -283,39 +285,32 @@ const TopicMergeModal: Component<TopicMergeModalProps> = (props) => {
<h4>Предпросмотр слияния:</h4> <h4>Предпросмотр слияния:</h4>
<ul> <ul>
<li> <li>
<strong>Целевая тема:</strong> {props.topics.find(t => t.id === targetTopicId())?.title} <strong>Целевая тема:</strong> {props.topics.find((t) => t.id === targetTopicId())?.title}
</li> </li>
<li> <li>
<strong>Исходные темы:</strong> {sourceTopicIds().length} шт. <strong>Исходные темы:</strong> {sourceTopicIds().length} шт.
<ul> <ul>
<For each={sourceTopicIds()}> <For each={sourceTopicIds()}>
{(id) => { {(id) => {
const topic = props.topics.find(t => t.id === id) const topic = props.topics.find((t) => t.id === id)
return topic ? <li>{topic.title}</li> : null return topic ? <li>{topic.title}</li> : null
}} }}
</For> </For>
</ul> </ul>
</li> </li>
<li> <li>
<strong>Действие:</strong> Все подписчики, публикации и черновики будут перенесены в целевую тему, исходные темы будут удалены <strong>Действие:</strong> Все подписчики, публикации и черновики будут перенесены в целевую
тему, исходные темы будут удалены
</li> </li>
</ul> </ul>
</div> </div>
</Show> </Show>
<div class={styles.modalActions}> <div class={styles.modalActions}>
<Button <Button variant="secondary" onClick={handleClose} disabled={loading()}>
variant="secondary"
onClick={handleClose}
disabled={loading()}
>
Отмена Отмена
</Button> </Button>
<Button <Button variant="danger" onClick={handleMerge} disabled={!canMerge() || loading()}>
variant="danger"
onClick={handleMerge}
disabled={!canMerge() || loading()}
>
{loading() ? 'Выполняется слияние...' : 'Слить темы'} {loading() ? 'Выполняется слияние...' : 'Слить темы'}
</Button> </Button>
</div> </div>

View File

@ -1,7 +1,7 @@
import { Component, createSignal, For, Show } from 'solid-js' import { Component, createSignal, For, Show } from 'solid-js'
import styles from '../styles/Form.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
interface Topic { interface Topic {
id: number id: number
@ -38,7 +38,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
const currentTopic = props.topic const currentTopic = props.topic
if (!currentTopic) return [] if (!currentTopic) return []
return props.allTopics.filter(topic => { return props.allTopics.filter((topic) => {
// Исключаем сам топик // Исключаем сам топик
if (topic.id === currentTopic.id) return false if (topic.id === currentTopic.id) return false
@ -58,7 +58,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
// Проверка, является ли топик потомком другого // Проверка, является ли топик потомком другого
const isDescendant = (ancestorId: number, descendantId: number): boolean => { const isDescendant = (ancestorId: number, descendantId: number): boolean => {
const descendant = props.allTopics.find(t => t.id === descendantId) const descendant = props.allTopics.find((t) => t.id === descendantId)
if (!descendant || !descendant.parent_ids) return false if (!descendant || !descendant.parent_ids) return false
return descendant.parent_ids.includes(ancestorId) return descendant.parent_ids.includes(ancestorId)
@ -66,7 +66,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
// Получение пути к корню для отображения полного пути // Получение пути к корню для отображения полного пути
const getTopicPath = (topicId: number): string => { const getTopicPath = (topicId: number): string => {
const topic = props.allTopics.find(t => t.id === topicId) const topic = props.allTopics.find((t) => t.id === topicId)
if (!topic) return '' if (!topic) return ''
if (!topic.parent_ids || topic.parent_ids.length === 0) { if (!topic.parent_ids || topic.parent_ids.length === 0) {
@ -86,7 +86,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
let newParentIds: number[] = [] let newParentIds: number[] = []
if (newParentId) { if (newParentId) {
const parentTopic = props.allTopics.find(t => t.id === newParentId) const parentTopic = props.allTopics.find((t) => t.id === newParentId)
if (parentTopic) { if (parentTopic) {
// Строим полный путь от корня до нового родителя // Строим полный путь от корня до нового родителя
newParentIds = [...(parentTopic.parent_ids || []), newParentId] newParentIds = [...(parentTopic.parent_ids || []), newParentId]
@ -128,10 +128,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
<div class={styles.currentSelection}> <div class={styles.currentSelection}>
<label class={styles.label}>Текущий родитель:</label> <label class={styles.label}>Текущий родитель:</label>
<div class={styles.currentParent}> <div class={styles.currentParent}>
<Show <Show when={getCurrentParentId()} fallback={<span class={styles.noParent}>Корневая тема</span>}>
when={getCurrentParentId()}
fallback={<span class={styles.noParent}>Корневая тема</span>}
>
<span class={styles.parentPath}> <span class={styles.parentPath}>
{getCurrentParentId() ? getTopicPath(getCurrentParentId()!) : ''} {getCurrentParentId() ? getTopicPath(getCurrentParentId()!) : ''}
</span> </span>
@ -153,9 +150,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
/> />
<label for="root-option" class={styles.parentOptionLabel}> <label for="root-option" class={styles.parentOptionLabel}>
<strong>🏠 Корневая тема</strong> <strong>🏠 Корневая тема</strong>
<div class={styles.parentDescription}> <div class={styles.parentDescription}>Переместить на верхний уровень иерархии</div>
Переместить на верхний уровень иерархии
</div>
</label> </label>
</div> </div>
@ -178,9 +173,7 @@ const TopicParentModal: Component<TopicParentModalProps> = (props) => {
<span class={styles.topicSlug}>{topic.slug}</span> <span class={styles.topicSlug}>{topic.slug}</span>
</div> </div>
<Show when={topic.parent_ids && topic.parent_ids.length > 0}> <Show when={topic.parent_ids && topic.parent_ids.length > 0}>
<div class={styles.parentPath}> <div class={styles.parentPath}>Путь: {getTopicPath(topic.id)}</div>
Путь: {getTopicPath(topic.id)}
</div>
</Show> </Show>
</label> </label>
</div> </div>

View File

@ -1,8 +1,8 @@
import { Component, createSignal, For, Show } from 'solid-js' import { Component, createSignal, For, Show } from 'solid-js'
import { SET_TOPIC_PARENT_MUTATION } from '../graphql/mutations'
import styles from '../styles/Form.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import styles from '../styles/Form.module.css'
import { SET_TOPIC_PARENT_MUTATION } from '../graphql/mutations'
// Типы для топиков // Типы для топиков
interface Topic { interface Topic {
@ -31,10 +31,12 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
* Получает токен авторизации * Получает токен авторизации
*/ */
const getAuthTokenFromCookie = () => { const getAuthTokenFromCookie = () => {
return document.cookie return (
.split('; ') document.cookie
.find(row => row.startsWith('auth_token=')) .split('; ')
?.split('=')[1] || '' .find((row) => row.startsWith('auth_token='))
?.split('=')[1] || ''
)
} }
/** /**
@ -51,7 +53,7 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
* Получает путь темы до корня * Получает путь темы до корня
*/ */
const getTopicPath = (topicId: number): string => { const getTopicPath = (topicId: number): string => {
const topic = props.allTopics.find(t => t.id === topicId) const topic = props.allTopics.find((t) => t.id === topicId)
if (!topic) return 'Неизвестная тема' if (!topic) return 'Неизвестная тема'
if (!topic.parent_ids || topic.parent_ids.length === 0) { if (!topic.parent_ids || topic.parent_ids.length === 0) {
@ -69,9 +71,7 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
if (parentId === childId) return true if (parentId === childId) return true
const checkDescendants = (currentId: number): boolean => { const checkDescendants = (currentId: number): boolean => {
const descendants = props.allTopics.filter(t => const descendants = props.allTopics.filter((t) => t?.parent_ids?.includes(currentId))
t.parent_ids && t.parent_ids.includes(currentId)
)
for (const descendant of descendants) { for (const descendant of descendants) {
if (descendant.id === childId || checkDescendants(descendant.id)) { if (descendant.id === childId || checkDescendants(descendant.id)) {
@ -92,7 +92,7 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
const query = searchQuery().toLowerCase() const query = searchQuery().toLowerCase()
return props.allTopics.filter(topic => { return props.allTopics.filter((topic) => {
// Исключаем саму тему // Исключаем саму тему
if (topic.id === props.topic!.id) return false if (topic.id === props.topic!.id) return false
@ -149,7 +149,6 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
props.onSuccess(setResult.message) props.onSuccess(setResult.message)
handleClose() handleClose()
} catch (error) { } catch (error) {
const errorMessage = (error as Error).message const errorMessage = (error as Error).message
props.onError(`Ошибка назначения родителя: ${errorMessage}`) props.onError(`Ошибка назначения родителя: ${errorMessage}`)
@ -169,12 +168,7 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
} }
return ( return (
<Modal <Modal isOpen={props.isOpen} onClose={handleClose} title="Назначить родительскую тему" size="medium">
isOpen={props.isOpen}
onClose={handleClose}
title="Назначить родительскую тему"
size="medium"
>
<div class={styles.parentSelectorContainer}> <div class={styles.parentSelectorContainer}>
<Show when={props.topic}> <Show when={props.topic}>
<div class={styles.currentSelection}> <div class={styles.currentSelection}>
@ -186,10 +180,11 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
<div class={styles.currentParent}> <div class={styles.currentParent}>
<strong>Текущее расположение:</strong> <strong>Текущее расположение:</strong>
<div class={styles.parentPath}> <div class={styles.parentPath}>
{getCurrentParentId() ? {getCurrentParentId() ? (
getTopicPath(props.topic!.id) : getTopicPath(props.topic!.id)
) : (
<span class={styles.noParent}>🏠 Корневая тема</span> <span class={styles.noParent}>🏠 Корневая тема</span>
} )}
</div> </div>
</div> </div>
</div> </div>
@ -220,9 +215,7 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
disabled={loading()} disabled={loading()}
/> />
<div class={styles.parentOptionLabel}> <div class={styles.parentOptionLabel}>
<div class={styles.topicTitle}> <div class={styles.topicTitle}>🏠 Сделать корневой темой</div>
🏠 Сделать корневой темой
</div>
<div class={styles.parentDescription}> <div class={styles.parentDescription}>
Тема будет перемещена на верхний уровень иерархии Тема будет перемещена на верхний уровень иерархии
</div> </div>
@ -244,9 +237,7 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
disabled={loading()} disabled={loading()}
/> />
<div class={styles.parentOptionLabel}> <div class={styles.parentOptionLabel}>
<div class={styles.topicTitle}> <div class={styles.topicTitle}>{topic.title}</div>
{topic.title}
</div>
<div class={styles.parentDescription}> <div class={styles.parentDescription}>
<span class={styles.topicId}>ID: {topic.id}</span> <span class={styles.topicId}>ID: {topic.id}</span>
<span class={styles.topicSlug}> {topic.slug}</span> <span class={styles.topicSlug}> {topic.slug}</span>
@ -261,10 +252,9 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
<Show when={getAvailableParents().length === 0}> <Show when={getAvailableParents().length === 0}>
<div class={styles.noResults}> <div class={styles.noResults}>
{searchQuery() ? {searchQuery()
'Не найдено подходящих тем по запросу' : ? 'Не найдено подходящих тем по запросу'
'Нет доступных родительских тем' : 'Нет доступных родительских тем'}
}
</div> </div>
</Show> </Show>
</div> </div>
@ -274,24 +264,21 @@ const TopicSimpleParentModal: Component<TopicSimpleParentModalProps> = (props) =
<div class={styles.preview}> <div class={styles.preview}>
<h4>Предварительный просмотр:</h4> <h4>Предварительный просмотр:</h4>
<div class={styles.previewPath}> <div class={styles.previewPath}>
<strong>Новое расположение:</strong><br /> <strong>Новое расположение:</strong>
<br />
{getTopicPath(selectedParentId()!)} <strong>{props.topic?.title}</strong> {getTopicPath(selectedParentId()!)} <strong>{props.topic?.title}</strong>
</div> </div>
</div> </div>
</Show> </Show>
<div class={styles.modalActions}> <div class={styles.modalActions}>
<Button <Button variant="secondary" onClick={handleClose} disabled={loading()}>
variant="secondary"
onClick={handleClose}
disabled={loading()}
>
Отмена Отмена
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={handleSetParent} onClick={handleSetParent}
disabled={loading() || (selectedParentId() === getCurrentParentId())} disabled={loading() || selectedParentId() === getCurrentParentId()}
> >
{loading() ? 'Назначение...' : 'Назначить родителя'} {loading() ? 'Назначение...' : 'Назначить родителя'}
</Button> </Button>

View File

@ -1,8 +1,5 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js' import { Component, createSignal, For, onMount, Show } from 'solid-js'
import { import { ADMIN_DELETE_INVITE_MUTATION, ADMIN_DELETE_INVITES_BATCH_MUTATION } from '../graphql/mutations'
ADMIN_DELETE_INVITE_MUTATION,
ADMIN_DELETE_INVITES_BATCH_MUTATION
} from '../graphql/mutations'
import { ADMIN_GET_INVITES_QUERY } from '../graphql/queries' import { ADMIN_GET_INVITES_QUERY } from '../graphql/queries'
import styles from '../styles/Table.module.css' import styles from '../styles/Table.module.css'
import Button from '../ui/Button' import Button from '../ui/Button'
@ -227,7 +224,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const deleteSelectedInvites = async () => { const deleteSelectedInvites = async () => {
try { try {
const selected = selectedInvites() const selected = selectedInvites()
const invitesToDelete = invites().filter(invite => { const invitesToDelete = invites().filter((invite) => {
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}` const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
return selected[key] return selected[key]
}) })
@ -239,7 +236,9 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
// Получаем токен авторизации из localStorage или cookie // Получаем токен авторизации из localStorage или cookie
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie() const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
console.log(`[InvitesRoute] Пакетное удаление приглашений, токен: ${authToken ? 'найден' : 'не найден'}`) console.log(
`[InvitesRoute] Пакетное удаление приглашений, токен: ${authToken ? 'найден' : 'не найден'}`
)
const response = await fetch('/graphql', { const response = await fetch('/graphql', {
method: 'POST', method: 'POST',
@ -250,7 +249,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
body: JSON.stringify({ body: JSON.stringify({
query: ADMIN_DELETE_INVITES_BATCH_MUTATION, query: ADMIN_DELETE_INVITES_BATCH_MUTATION,
variables: { variables: {
invites: invitesToDelete.map(invite => ({ invites: invitesToDelete.map((invite) => ({
inviter_id: invite.inviter_id, inviter_id: invite.inviter_id,
author_id: invite.author_id, author_id: invite.author_id,
shout_id: invite.shout_id shout_id: invite.shout_id
@ -286,7 +285,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
*/ */
const handleSelectInvite = (invite: Invite, checked: boolean) => { const handleSelectInvite = (invite: Invite, checked: boolean) => {
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}` const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
setSelectedInvites(prev => ({ ...prev, [key]: checked })) setSelectedInvites((prev) => ({ ...prev, [key]: checked }))
// Если снимаем выбор с элемента, то снимаем и "выбрать все" // Если снимаем выбор с элемента, то снимаем и "выбрать все"
if (!checked && selectAll()) { if (!checked && selectAll()) {
@ -303,7 +302,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const newSelected: Record<string, boolean> = {} const newSelected: Record<string, boolean> = {}
if (checked) { if (checked) {
// Выбираем все приглашения на текущей странице // Выбираем все приглашения на текущей странице
invites().forEach(invite => { invites().forEach((invite) => {
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}` const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
newSelected[key] = true newSelected[key] = true
}) })
@ -406,9 +405,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
</div> </div>
<Show when={getSelectedCount() > 0}> <Show when={getSelectedCount() > 0}>
<div class={styles['selected-count']}> <div class={styles['selected-count']}>Выбрано: {getSelectedCount()}</div>
Выбрано: {getSelectedCount()}
</div>
<button <button
class={styles['batch-delete-button']} class={styles['batch-delete-button']}
@ -434,7 +431,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
<table class={styles.table}> <table class={styles.table}>
<thead> <thead>
<tr> <tr>
<th class={styles['checkbox-column']}></th> <th class={styles['checkbox-column']} />
<th class={styles.sortableHeader} onClick={() => handleSort('inviter_name')}> <th class={styles.sortableHeader} onClick={() => handleSort('inviter_name')}>
<span class={styles.headerContent}> <span class={styles.headerContent}>
Приглашающий Приглашающий
@ -577,10 +574,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
<Button variant="secondary" onClick={() => setBatchDeleteModal({ show: false })}> <Button variant="secondary" onClick={() => setBatchDeleteModal({ show: false })}>
Отмена Отмена
</Button> </Button>
<Button <Button variant="danger" onClick={deleteSelectedInvites}>
variant="danger"
onClick={deleteSelectedInvites}
>
Удалить выбранные Удалить выбранные
</Button> </Button>
</div> </div>

View File

@ -326,9 +326,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
*/ */
const handleTopicSelect = (topicId: number, checked: boolean) => { const handleTopicSelect = (topicId: number, checked: boolean) => {
if (checked) { if (checked) {
setSelectedTopics(prev => [...prev, topicId]) setSelectedTopics((prev) => [...prev, topicId])
} else { } else {
setSelectedTopics(prev => prev.filter(id => id !== topicId)) setSelectedTopics((prev) => prev.filter((id) => id !== topicId))
} }
} }
@ -337,7 +337,7 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
*/ */
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
const allTopicIds = rawTopics().map(topic => topic.id) const allTopicIds = rawTopics().map((topic) => topic.id)
setSelectedTopics(allTopicIds) setSelectedTopics(allTopicIds)
} else { } else {
setSelectedTopics([]) setSelectedTopics([])
@ -348,9 +348,9 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
* Проверяет выбраны ли все топики * Проверяет выбраны ли все топики
*/ */
const isAllSelected = () => { const isAllSelected = () => {
const allIds = rawTopics().map(topic => topic.id) const allIds = rawTopics().map((topic) => topic.id)
const selected = selectedTopics() const selected = selectedTopics()
return allIds.length > 0 && allIds.every(id => selected.includes(id)) return allIds.length > 0 && allIds.every((id) => selected.includes(id))
} }
/** /**
@ -372,7 +372,7 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
if (action === 'delete') { if (action === 'delete') {
// Групповое удаление // Групповое удаление
const selectedTopicsData = rawTopics().filter(t => selected.includes(t.id)) const selectedTopicsData = rawTopics().filter((t) => selected.includes(t.id))
setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения
} else if (action === 'merge') { } else if (action === 'merge') {
// Слияние топиков // Слияние топиков
@ -482,7 +482,7 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
if (selectedTopics().length === 1) { if (selectedTopics().length === 1) {
const selectedTopic = rawTopics().find(t => t.id === selectedTopics()[0]) const selectedTopic = rawTopics().find((t) => t.id === selectedTopics()[0])
if (selectedTopic) { if (selectedTopic) {
setSimpleParentModal({ show: true, topic: selectedTopic }) setSimpleParentModal({ show: true, topic: selectedTopic })
} }
@ -515,7 +515,14 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
<th>Сообщество</th> <th>Сообщество</th>
<th>Родители</th> <th>Родители</th>
<th> <th>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', 'flex-direction': 'column' }}> <div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px',
'flex-direction': 'column'
}}
>
<div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}> <div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
<input <input
type="checkbox" type="checkbox"
@ -619,16 +626,16 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}> <Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
Отмена Отмена
</Button> </Button>
<Button <Button
variant="danger" variant="danger"
onClick={() => { onClick={() => {
if (deleteModal().topic) { if (deleteModal().topic) {
void deleteTopic(deleteModal().topic!.id) void deleteTopic(deleteModal().topic!.id)
} }
}} }}
> >
Удалить Удалить
</Button> </Button>
</div> </div>
</Show> </Show>
</div> </div>
@ -642,7 +649,7 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
setSelectedTopics([]) setSelectedTopics([])
setGroupAction('') setGroupAction('')
}} }}
topics={rawTopics().filter(topic => selectedTopics().includes(topic.id))} topics={rawTopics().filter((topic) => selectedTopics().includes(topic.id))}
onSuccess={(message) => { onSuccess={(message) => {
props.onSuccess(message) props.onSuccess(message)
setSelectedTopics([]) setSelectedTopics([])

View File

@ -1,33 +1,45 @@
.codePreview { .codePreview {
position: relative; position: relative;
padding-left: 50px !important; padding-left: 24px !important;
background-color: #2d2d2d; background-color: #2d2d2d;
color: #f8f8f2; color: #f8f8f2;
tab-size: 2; tab-size: 2;
line-height: 1.4; line-height: 1.4;
border-radius: 4px;
overflow: hidden; overflow: hidden;
font-size: 12px; font-size: 12px;
} }
.lineNumber { .lineNumber {
display: block; display: block;
padding: 0 8px; padding: 0 2px;
text-align: right; text-align: right;
color: #555; color: #555;
background: #1e1e1e; background: #1e1e1e;
user-select: none; user-select: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11px; font-size: 9px;
line-height: 1.4; line-height: 1.4;
min-height: 16.8px; /* 12px * 1.4 line-height */ min-height: 12.6px; /* 9px * 1.4 line-height */
border-right: 1px solid rgba(255, 255, 255, 0.1); border-right: 1px solid rgba(255, 255, 255, 0.1);
opacity: 0.7; opacity: 0.7;
pointer-events: none; pointer-events: none;
} }
.lineNumbersContainer { .lineNumbersContainer {
position: absolute;
left: 0;
top: 0;
width: 24px;
height: 100%;
background: #1e1e1e;
border-right: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden; overflow: hidden;
user-select: none;
padding: 8px 2px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 9px;
line-height: 1.4;
text-align: right;
} }
.lineNumbersContainer .lineNumber { .lineNumbersContainer .lineNumber {
@ -48,14 +60,13 @@
color: #fff; color: #fff;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
z-index: 100; z-index: 10;
} }
/* Стили для EditableCodePreview */ /* Стили для EditableCodePreview */
.editableCodeContainer { .editableCodeContainer {
position: relative; position: relative;
background-color: #2d2d2d; background-color: #2d2d2d;
border-radius: 6px;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
display: flex; display: flex;
@ -132,15 +143,37 @@
} }
.syntaxHighlight { .syntaxHighlight {
position: absolute;
top: 0;
left: 24px;
right: 0;
bottom: 0;
pointer-events: none;
color: transparent;
background: transparent;
margin: 0;
padding: 8px 8px;
width: 100%; width: 100%;
height: 100%; height: 100%;
tab-size: 2; tab-size: 2;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
overflow: hidden;
z-index: 0;
} }
.editorArea { .editorArea {
position: absolute;
top: 0;
left: 24px;
right: 0;
bottom: 0;
z-index: 1;
margin: 0;
padding: 8px 8px;
resize: none; resize: none;
border: none; border: none;
width: 100%; width: 100%;
@ -149,12 +182,66 @@
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
outline: none;
} }
.editorArea:focus { .editorArea:focus {
outline: none; outline: none;
} }
.editorAreaEditing {
background: rgba(0, 0, 0, 0.02);
color: rgba(255, 255, 255, 0.9);
cursor: text;
caret-color: #fff;
}
.editorAreaViewing {
background: transparent;
color: transparent;
cursor: default;
caret-color: transparent;
}
.editorWrapperEditing {
border: 2px solid #007acc;
}
.codePreviewContainer {
position: absolute;
top: 0;
left: 24px;
right: 0;
bottom: 0;
margin: 0;
padding: 8px 8px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
cursor: pointer;
overflow-y: auto;
z-index: 2;
}
.placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #666;
cursor: pointer;
font-style: italic;
font-size: 14px;
pointer-events: none;
user-select: none;
}
.placeholder { .placeholder {
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;

View File

@ -28,10 +28,10 @@ const formatHtmlContent = (html: string): string => {
if (!html || typeof html !== 'string') return '' if (!html || typeof html !== 'string') return ''
// Удаляем лишние пробелы между тегами // Удаляем лишние пробелы между тегами
let formatted = html const formatted = html
.replace(/>\s+</g, '><') // Убираем пробелы между тегами .replace(/>\s+</g, '><') // Убираем пробелы между тегами
.replace(/\s+/g, ' ') // Множественные пробелы в одиночные .replace(/\s+/g, ' ') // Множественные пробелы в одиночные
.trim() // Убираем пробелы в начале и конце .trim() // Убираем пробелы в начале и конце
// Добавляем отступы для лучшего отображения // Добавляем отступы для лучшего отображения
const indent = ' ' const indent = ' '
@ -117,7 +117,7 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
const lineNumbers = generateLineNumbers(content()) const lineNumbers = generateLineNumbers(content())
lineNumbersRef.innerHTML = lineNumbers lineNumbersRef.innerHTML = lineNumbers
.map(num => `<div class="${styles.lineNumber}">${num}</div>`) .map((num) => `<div class="${styles.lineNumber}">${num}</div>`)
.join('') .join('')
} }
@ -243,9 +243,8 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
// Эффект для обновления контента при изменении props // Эффект для обновления контента при изменении props
createEffect(() => { createEffect(() => {
if (!isEditing()) { if (!isEditing()) {
const formattedContent = language() === 'markup' || language() === 'html' const formattedContent =
? formatHtmlContent(props.content) language() === 'markup' || language() === 'html' ? formatHtmlContent(props.content) : props.content
: props.content
setContent(formattedContent) setContent(formattedContent)
updateHighlight() updateHighlight()
updateLineNumbers() updateLineNumbers()
@ -301,9 +300,8 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
}) })
onMount(() => { onMount(() => {
const formattedContent = language() === 'markup' || language() === 'html' const formattedContent =
? formatHtmlContent(props.content) language() === 'markup' || language() === 'html' ? formatHtmlContent(props.content) : props.content
: props.content
setContent(formattedContent) setContent(formattedContent)
updateHighlight() updateHighlight()
updateLineNumbers() updateLineNumbers()
@ -313,22 +311,17 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
<div class={styles.editableCodeContainer}> <div class={styles.editableCodeContainer}>
{/* Контейнер редактора - увеличиваем размер */} {/* Контейнер редактора - увеличиваем размер */}
<div <div
class={styles.editorWrapper} class={`${styles.editorWrapper} ${isEditing() ? styles.editorWrapperEditing : ''}`}
style={`height: 100%; ${isEditing() ? 'border: 2px solid #007acc;' : ''}`} style="height: 100%;"
> >
{/* Номера строк */} {/* Номера строк */}
<div <div ref={lineNumbersRef} class={styles.lineNumbersContainer} />
ref={lineNumbersRef}
class={styles.lineNumbersContainer}
style="position: absolute; left: 0; top: 0; width: 50px; height: 100%; background: #1e1e1e; border-right: 1px solid rgba(255, 255, 255, 0.1); overflow: hidden; user-select: none; padding: 8px 0; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; line-height: 1.4;"
/>
{/* Подсветка синтаксиса (фон) - только в режиме редактирования */} {/* Подсветка синтаксиса (фон) - только в режиме редактирования */}
<Show when={isEditing()}> <Show when={isEditing()}>
<pre <pre
ref={highlightRef} ref={highlightRef}
class={`${styles.syntaxHighlight} language-${language()}`} class={`${styles.syntaxHighlight} language-${language()}`}
style="position: absolute; top: 0; left: 50px; right: 0; bottom: 0; pointer-events: none; color: transparent; background: transparent; margin: 0; padding: 8px 12px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; overflow: hidden; z-index: 0;"
aria-hidden="true" aria-hidden="true"
/> />
</Show> </Show>
@ -343,28 +336,7 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
} }
}} }}
contentEditable={isEditing()} contentEditable={isEditing()}
class={styles.editorArea} class={`${styles.editorArea} ${isEditing() ? styles.editorAreaEditing : styles.editorAreaViewing}`}
style={`
position: absolute;
top: 0;
left: 50px;
right: 0;
bottom: 0;
z-index: 1;
background: ${isEditing() ? 'rgba(0, 0, 0, 0.02)' : 'transparent'};
color: ${isEditing() ? 'rgba(255, 255, 255, 0.9)' : 'transparent'};
margin: 0;
padding: 8px 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
outline: none;
cursor: ${isEditing() ? 'text' : 'default'};
caret-color: ${isEditing() ? '#fff' : 'transparent'};
`}
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onScroll={syncScroll} onScroll={syncScroll}
@ -374,25 +346,7 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
{/* Превью для неактивного режима */} {/* Превью для неактивного режима */}
<Show when={!isEditing()}> <Show when={!isEditing()}>
<pre <pre
class={`${styles.codePreview} language-${language()}`} class={`${styles.codePreviewContainer} language-${language()}`}
style={`
position: absolute;
top: 0;
left: 50px;
right: 0;
bottom: 0;
margin: 0;
padding: 8px 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
cursor: pointer;
overflow-y: auto;
z-index: 2;
`}
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
onScroll={(e) => { onScroll={(e) => {
// Синхронизируем номера строк при скролле в режиме просмотра // Синхронизируем номера строк при скролле в режиме просмотра
@ -416,29 +370,26 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
</div> </div>
{/* Индикатор языка */} {/* Индикатор языка */}
<span class={styles.languageBadge} style="top: 8px; right: 8px; z-index: 10;"> <span class={styles.languageBadge}>{language()}</span>
{language()}
</span>
{/* Плейсхолдер */} {/* Плейсхолдер */}
<Show when={!content()}> <Show when={!content()}>
<div <div class={styles.placeholder} onClick={() => setIsEditing(true)}>
class={styles.placeholder}
onClick={() => setIsEditing(true)}
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; cursor: pointer; font-style: italic; font-size: 14px;"
>
{props.placeholder || 'Нажмите для редактирования...'} {props.placeholder || 'Нажмите для редактирования...'}
</div> </div>
</Show> </Show>
{/* Кнопки управления внизу */} {/* Кнопки управления внизу */}
{props.showButtons !== false && ( <Show when={props.showButtons}>
<div class={styles.editorControls} style="border-top: 1px solid rgba(255, 255, 255, 0.1); border-bottom: none; background-color: #1e1e1e;"> <div class={styles.editorControls}>
<Show when={isEditing()} fallback={ <Show
<button class={styles.editButton} onClick={() => setIsEditing(true)}> when={isEditing()}
Редактировать fallback={
</button> <button class={styles.editButton} onClick={() => setIsEditing(true)}>
}> Редактировать
</button>
}
>
<div class={styles.editingControls}> <div class={styles.editingControls}>
<button class={styles.saveButton} onClick={handleSave}> <button class={styles.saveButton} onClick={handleSave}>
💾 Сохранить (Ctrl+Enter) 💾 Сохранить (Ctrl+Enter)
@ -449,7 +400,7 @@ const EditableCodePreview = (props: EditableCodePreviewProps) => {
</div> </div>
</Show> </Show>
</div> </div>
)} </Show>
</div> </div>
) )
} }