This commit is contained in:
252
panel/ui/TopicPillsCloud.tsx
Normal file
252
panel/ui/TopicPillsCloud.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Компонент облака топиков для выбора родительских тем
|
||||
* @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
|
Reference in New Issue
Block a user