253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
|
/**
|
|||
|
* Компонент облака топиков для выбора родительских тем
|
|||
|
* @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
|