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:
Alireza Hassani
2026-08-17 00:30:01 +03:30
co-authored by Cursor
parent b9024ad77a
commit e0e68de30f
15 changed files with 1164 additions and 77 deletions
@@ -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>
)
}