Add a reusable website link generator and polish static image cards.
Editors can pick product, store, blog, portfolio, or video category links, and image cards expose compact info/link actions without dumping URLs in the footer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b9024ad77a
commit
e0e68de30f
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { Category } from '../types/category'
|
||||
import { getAncestorIds, getChildren } from '../utils/categories'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import treeStyles from './CategoryTree.module.css'
|
||||
import styles from './CategoryTreeSelect.module.css'
|
||||
|
||||
interface CategoryTreeSelectProps {
|
||||
categories: Category[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
function categoryLabel(category: Category, isFa: boolean) {
|
||||
if (isFa) {
|
||||
return category.nameFa
|
||||
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
||||
: category.nameEn
|
||||
}
|
||||
return category.nameEn
|
||||
? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}`
|
||||
: category.nameFa
|
||||
}
|
||||
|
||||
function categoryMatches(category: Category, query: string) {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return (
|
||||
category.nameEn.toLowerCase().includes(q) ||
|
||||
category.nameFa.toLowerCase().includes(q) ||
|
||||
category.nameFa.includes(query.trim())
|
||||
)
|
||||
}
|
||||
|
||||
function visibleIdsForQuery(categories: Category[], query: string): Set<string> | null {
|
||||
const q = query.trim()
|
||||
if (!q) return null
|
||||
|
||||
const matched = categories.filter((category) => categoryMatches(category, q))
|
||||
const visible = new Set<string>()
|
||||
|
||||
for (const category of matched) {
|
||||
visible.add(category.id)
|
||||
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
||||
visible.add(ancestorId)
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
function TreeNodes({
|
||||
categories,
|
||||
parentId,
|
||||
value,
|
||||
expandedIds,
|
||||
visibleIds,
|
||||
forceExpanded,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
categories: Category[]
|
||||
parentId: string | null
|
||||
value: string
|
||||
expandedIds: Set<string>
|
||||
visibleIds: Set<string> | null
|
||||
forceExpanded: boolean
|
||||
onToggle: (id: string) => void
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const nodes = getChildren(categories, parentId).filter(
|
||||
(category) => !visibleIds || visibleIds.has(category.id),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((category) => {
|
||||
const childCount = getChildren(categories, category.id).some(
|
||||
(child) => !visibleIds || visibleIds.has(child.id),
|
||||
)
|
||||
const expanded = forceExpanded || expandedIds.has(category.id)
|
||||
const selected = value === category.id
|
||||
const primary = isFa ? category.nameFa || category.nameEn : category.nameEn
|
||||
const secondary = isFa
|
||||
? category.nameFa && category.nameEn && category.nameFa !== category.nameEn
|
||||
? category.nameEn
|
||||
: ''
|
||||
: category.nameFa
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<div className={`${styles.row} ${selected ? styles.rowSelected : ''}`}>
|
||||
{childCount ? (
|
||||
<Tooltip
|
||||
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleBtn}
|
||||
aria-label={
|
||||
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggle(category.id)}
|
||||
>
|
||||
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={styles.toggleSpacer} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.selectBtn}
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<span className={styles.name}>{primary}</span>
|
||||
{secondary ? (
|
||||
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
|
||||
· {secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{childCount ? (
|
||||
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
|
||||
<div className={treeStyles.childrenInner}>
|
||||
<TreeNodes
|
||||
categories={categories}
|
||||
parentId={category.id}
|
||||
value={value}
|
||||
expandedIds={expandedIds}
|
||||
visibleIds={visibleIds}
|
||||
forceExpanded={forceExpanded}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryTreeSelect({
|
||||
categories,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
id,
|
||||
}: CategoryTreeSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selected = categories.find((category) => category.id === value)
|
||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
||||
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
||||
|
||||
const visibleIds = useMemo(
|
||||
() => visibleIdsForQuery(categories, query),
|
||||
[categories, query],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setExpandedIds(new Set())
|
||||
}, [categories])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const ancestorId of getAncestorIds(categories, value)) {
|
||||
next.add(ancestorId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [categories, value])
|
||||
|
||||
function handleToggle(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectOption(id: string) {
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const searching = query.trim().length > 0
|
||||
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen(true)
|
||||
}}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{value && !open && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label={t('categories.select.clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.fieldChevron} ${open ? styles.fieldChevronOpen : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && !disabled ? (
|
||||
<div className={styles.panel}>
|
||||
{!hasVisible ? (
|
||||
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
||||
) : (
|
||||
<TreeNodes
|
||||
categories={categories}
|
||||
parentId={null}
|
||||
value={value}
|
||||
expandedIds={expandedIds}
|
||||
visibleIds={visibleIds}
|
||||
forceExpanded={searching}
|
||||
onToggle={handleToggle}
|
||||
onSelect={selectOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user