core/panel/ui/TopicPillsCloud.tsx
Untone eb2140bcc6
All checks were successful
Deploy on push / deploy (push) Successful in 6s
0.7.7-topics-editing
2025-07-03 12:15:10 +03:00

253 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Компонент облака топиков для выбора родительских тем
* @module TopicPillsCloud
*/
import { createSignal, createMemo, For, Show } from 'solid-js'
import styles from '../styles/Form.module.css'
/**
* Интерфейс для топика
*/
export interface TopicPill {
id: string
title: string
slug: string
community: string
parent_ids?: string[]
depth?: number
}
/**
* Пропсы компонента TopicPillsCloud
*/
interface TopicPillsCloudProps {
/** Доступные топики для выбора */
topics: TopicPill[]
/** Выбранные топики */
selectedTopics: string[]
/** Колбек при изменении выбора */
onSelectionChange: (selectedIds: string[]) => void
/** Фильтр по сообществу */
communityFilter?: string
/** Исключить топики (например, текущий редактируемый) */
excludeTopics?: string[]
/** Максимальное количество выбранных топиков */
maxSelection?: number
/** Заголовок компонента */
title?: string
/** Показать поиск */
showSearch?: boolean
/** Плейсхолдер для поиска */
searchPlaceholder?: string
/** Класс для стилизации */
class?: string
/** Скрыть выбранные элементы в заголовке (показывать только в основном списке) */
hideSelectedInHeader?: boolean
}
/**
* Компонент облака топиков для выбора
*/
const TopicPillsCloud = (props: TopicPillsCloudProps) => {
const [searchQuery, setSearchQuery] = createSignal('')
/**
* Фильтрованные и отсортированные топики
*/
const filteredTopics = createMemo(() => {
let topics = props.topics
// Исключаем запрещенные топики
if (props.excludeTopics?.length) {
topics = topics.filter(topic => !props.excludeTopics!.includes(topic.id))
}
// Фильтруем по поисковому запросу
const query = searchQuery().toLowerCase().trim()
if (query) {
topics = topics.filter(topic =>
topic.title.toLowerCase().includes(query) ||
topic.slug.toLowerCase().includes(query)
)
}
// Умная сортировка: выбранные → релевантные → остальные
return topics.sort((a, b) => {
const aSelected = props.selectedTopics.includes(a.id)
const bSelected = props.selectedTopics.includes(b.id)
// Сначала выбранные топики
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
// Для не выбранных: приоритет топикам из того же сообщества
if (props.communityFilter) {
const aSameCommunity = a.community === props.communityFilter
const bSameCommunity = b.community === props.communityFilter
if (aSameCommunity && !bSameCommunity) return -1
if (!aSameCommunity && bSameCommunity) return 1
}
// Потом по алфавиту
return a.title.localeCompare(b.title, 'ru')
})
})
/**
* Обработчик клика по топику
*/
const handleTopicClick = (topicId: string) => {
const currentSelection = [...props.selectedTopics]
const index = currentSelection.indexOf(topicId)
if (index >= 0) {
// Убираем из выбора
currentSelection.splice(index, 1)
} else {
// Добавляем в выбор (если не превышен лимит)
if (!props.maxSelection || currentSelection.length < props.maxSelection) {
currentSelection.push(topicId)
}
}
props.onSelectionChange(currentSelection)
}
/**
* Проверяет, выбран ли топик
*/
const isSelected = (topicId: string) => props.selectedTopics.includes(topicId)
/**
* Проверяет, можно ли выбрать еще топики
*/
const canSelectMore = () => {
return !props.maxSelection || props.selectedTopics.length < props.maxSelection
}
/**
* Получает уровень вложенности топика
*/
const getTopicDepth = (topic: TopicPill): number => {
if (topic.depth !== undefined) return topic.depth
return topic.parent_ids?.length || 0
}
/**
* Получить выбранные топики как объекты
*/
const selectedTopicObjects = createMemo(() => {
return props.topics.filter(topic => props.selectedTopics.includes(topic.id))
})
return (
<div class={`${styles.topicPillsCloud} ${props.class || ''}`}>
{/* Поиск в самом верху */}
<Show when={props.showSearch}>
<div class={styles.pillsSearchContainer}>
<input
type="text"
class={`${styles.input} ${styles.pillsSearchInput}`}
placeholder={props.searchPlaceholder || 'Поиск...'}
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
/>
<Show when={searchQuery().trim()}>
<button
type="button"
class={styles.clearSearchBtn}
onClick={() => setSearchQuery('')}
title="Очистить поиск"
>
×
</button>
</Show>
</div>
</Show>
{/* Заголовок и выбранные топики */}
<Show when={props.title || (props.selectedTopics.length > 0 && !props.hideSelectedInHeader)}>
<div class={styles.pillsCloudHeader}>
<div class={styles.headerSection}>
<Show when={props.title}>
<h4 class={styles.pillsCloudTitle}>{props.title}</h4>
</Show>
<Show when={props.selectedTopics.length > 0 && !props.hideSelectedInHeader}>
<div class={styles.selectedTopicsDisplay}>
<span class={styles.selectedLabel}>Выбрано ({props.selectedTopics.length}):</span>
<div class={styles.selectedTopicsContainer}>
<For each={selectedTopicObjects()}>
{(topic) => (
<button
type="button"
class={`${styles.topicPill} ${styles.pillSelected} ${styles.pillCompact}`}
onClick={() => handleTopicClick(topic.id)}
title={`Убрать ${topic.title}`}
>
<span class={styles.pillTitle}>{topic.title}</span>
<span class={styles.pillRemoveIcon}>×</span>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
</Show>
<div class={styles.pillsContainer}>
<For each={filteredTopics()}>
{(topic) => {
const selected = isSelected(topic.id)
const disabled = !selected && !canSelectMore()
const depth = getTopicDepth(topic)
return (
<button
type="button"
class={`${styles.topicPill} ${
selected ? styles.pillSelected : ''
} ${disabled ? styles.pillDisabled : ''} ${
depth > 0 ? styles.pillNested : ''
}`}
onClick={() => !disabled && handleTopicClick(topic.id)}
disabled={disabled}
title={`${topic.title} (${topic.slug})`}
data-depth={depth}
>
<Show when={depth > 0}>
<span class={styles.pillDepthIndicator}>
{' '.repeat(depth)}
</span>
</Show>
<span class={styles.pillTitle}>{topic.title}</span>
<Show when={selected}>
<span class={styles.pillRemoveIcon}>×</span>
</Show>
</button>
)
}}
</For>
</div>
<Show when={filteredTopics().length === 0}>
<div class={styles.emptyState}>
<Show
when={searchQuery().trim()}
fallback={<span>Нет доступных топиков</span>}
>
<span>Ничего не найдено по запросу "{searchQuery()}"</span>
</Show>
</div>
</Show>
</div>
)
}
export default TopicPillsCloud