"use client"; import { useTemplates, fetchTemplateExample, SCENARIO_ORDER, scenarioLabelKey, type TemplateDef, } from "@/lib/templates"; import { useStore, selectActiveTask } from "@/lib/store"; import { useT } from "@/lib/i18n"; import { useEffect, useMemo, useRef, useState } from "react"; type Filter = "all" | string; function rankFeatured(t: TemplateDef): number { // smaller comes first; templates without `featured` go to the back of the bucket return typeof t.featured === "number" ? t.featured : 9999; } function rankRecommended(t: TemplateDef): number { return typeof t.recommended === "number" ? t.recommended : 9999; } /** Synthetic scenario key for the curated "Featured" group at the top of the picker. */ const FEATURED_KEY = "_featured"; function matchesQuery(tpl: TemplateDef, q: string): boolean { if (!q) return true; const hay = [ tpl.zhName, tpl.enName, tpl.description, tpl.aspectHint, tpl.category, tpl.scenario, ...(tpl.tags ?? []), ] .join(" ") .toLowerCase(); return q .toLowerCase() .split(/\s+/) .filter(Boolean) .every((tok) => hay.includes(tok)); } export function TemplatePicker() { const templates = useTemplates(); const selectedId = useStore((s) => selectActiveTask(s)?.templateId ?? "article-magazine"); const setSelected = useStore((s) => s.setSelectedTemplate); const loadSample = useStore((s) => s.loadSample); const locale = useStore((s) => s.locale); const t = useT(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); const [loadingPreview, setLoadingPreview] = useState(null); const ref = useRef(null); const inputRef = useRef(null); const list = templates ?? []; const tpl = list.find((tplDef) => tplDef.id === selectedId) ?? list[0]; const scenarioText = (s: string) => { const key = scenarioLabelKey(s); return key ? t(key) : s; }; const templateName = (def: TemplateDef) => (locale === "en" ? def.enName : def.zhName); // hover preview state — load + cache example.html lazily on hover. const [hoveredId, setHoveredId] = useState(null); const previewCache = useRef>(new Map()); const [, forceRender] = useState(0); const [hoverLoading, setHoverLoading] = useState(false); const hoverEnterTimer = useRef(null); const hoverLeaveTimer = useRef(null); useEffect(() => { if (!hoveredId) { setHoverLoading(false); return; } if (previewCache.current.has(hoveredId)) { setHoverLoading(false); return; } let cancelled = false; setHoverLoading(true); void (async () => { const ex = await fetchTemplateExample(hoveredId); if (cancelled) return; if (ex?.html) { previewCache.current.set(hoveredId, ex.html); forceRender((n) => n + 1); } setHoverLoading(false); })(); return () => { cancelled = true; }; }, [hoveredId]); const hoveredTpl = hoveredId ? list.find((def) => def.id === hoveredId) ?? null : null; const hoveredHtml = hoveredId ? previewCache.current.get(hoveredId) ?? null : null; useEffect(() => { const handler = (e: MouseEvent) => { if (open && ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); // focus search when opening; reset transient state on close useEffect(() => { if (open) { const id = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(id); } setQuery(""); setFilter("all"); }, [open]); // esc closes useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open]); const hasRecommended = useMemo( () => list.some((tpl) => typeof tpl.recommended === "number"), [list], ); const filterChips = useMemo(() => { const present = new Set(list.map((def) => def.scenario)); const ordered = SCENARIO_ORDER.filter((s) => present.has(s)); const extras = Array.from(present).filter((s) => !scenarioLabelKey(s)); return [ { id: "all", label: t("template.filter.all") }, ...(hasRecommended ? [{ id: FEATURED_KEY, label: t("template.filter.featured") }] : []), ...ordered.map((s) => ({ id: s, label: scenarioText(s) })), ...extras.map((s) => ({ id: s, label: s })), ]; }, [list, hasRecommended, t, scenarioText]); const grouped = useMemo(() => { // explicit "Featured" filter: show only recommended, sorted by recommended rank if (filter === FEATURED_KEY) { const items = list .filter((tpl) => typeof tpl.recommended === "number" && matchesQuery(tpl, query)) .sort( (a, b) => rankRecommended(a) - rankRecommended(b) || a.zhName.localeCompare(b.zhName, "zh"), ); return [{ key: FEATURED_KEY, label: t("template.filter.featured"), items }]; } const filtered = list .filter((tpl) => (filter === "all" || tpl.scenario === filter) && matchesQuery(tpl, query)) .sort((a, b) => rankFeatured(a) - rankFeatured(b) || a.zhName.localeCompare(b.zhName, "zh")); // scenario filter or active search: flat result list, no recommended group if (filter !== "all" || query) { return [{ key: "_results", label: "", items: filtered }]; } // default "全部" view: 推荐 group first, then scenarios (with recommended templates removed // so each template appears once) const recommendedItems = filtered .filter((tpl) => typeof tpl.recommended === "number") .sort( (a, b) => rankRecommended(a) - rankRecommended(b) || a.zhName.localeCompare(b.zhName, "zh"), ); const rest = filtered.filter((def) => typeof def.recommended !== "number"); const buckets = new Map(); for (const def of rest) { if (!buckets.has(def.scenario)) buckets.set(def.scenario, []); buckets.get(def.scenario)!.push(def); } const present = SCENARIO_ORDER.filter((s) => buckets.has(s)); const extras = Array.from(buckets.keys()).filter((s) => !scenarioLabelKey(s)); const scenarioGroups = [...present, ...extras].map((s) => ({ key: s, label: scenarioText(s), items: buckets.get(s)!, })); return [ ...(recommendedItems.length > 0 ? [{ key: FEATURED_KEY, label: t("template.filter.featured"), items: recommendedItems }] : []), ...scenarioGroups, ]; }, [list, filter, query, t, scenarioText]); const total = useMemo( () => grouped.reduce((n, g) => n + g.items.length, 0), [grouped], ); const onPreview = async (def: TemplateDef) => { setLoadingPreview(def.id); try { const ex = await fetchTemplateExample(def.id); if (!ex) return; loadSample({ id: ex.id, name: ex.name, content: ex.content, format: ex.format, templateId: ex.templateId, html: ex.html, }); setOpen(false); } finally { setLoadingPreview(null); } }; if (!tpl) { return ( ); } return (
{open && (
{/* sticky header: title + search + chips */}
{t("template.heading")}
{total} / {list.length}
setQuery(e.target.value)} placeholder={t("template.search.placeholder")} className="flex-1 bg-transparent outline-none text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-faint)]" /> {query && ( )}
{filterChips.map((c) => { const active = filter === c.id; return ( ); })}
{/* scrollable list */}
{total === 0 ? (
{templates === undefined ? t("template.empty.loading") : t("template.empty.noMatch")}
) : ( grouped.map((group) => (
{group.label && (
{group.label}
)}
{group.items.map((def) => { const isSelected = selectedId === def.id; const hasPreview = !!def.example?.hasHtml; const isLoading = loadingPreview === def.id; return (
{ if (!def.example?.hasHtml) return; if (hoverLeaveTimer.current) { window.clearTimeout(hoverLeaveTimer.current); hoverLeaveTimer.current = null; } if (hoverEnterTimer.current) window.clearTimeout(hoverEnterTimer.current); hoverEnterTimer.current = window.setTimeout(() => { setHoveredId(def.id); }, 260); }} onMouseLeave={() => { if (hoverEnterTimer.current) { window.clearTimeout(hoverEnterTimer.current); hoverEnterTimer.current = null; } if (hoverLeaveTimer.current) window.clearTimeout(hoverLeaveTimer.current); hoverLeaveTimer.current = window.setTimeout(() => { setHoveredId((prev) => (prev === def.id ? null : prev)); }, 180); }} >
{def.example?.source && ( e.stopPropagation()} className="rounded-full px-2 py-1 text-[10.5px] font-medium transition-all hover:text-[var(--ink)]" style={{ background: "transparent", color: "var(--ink-faint)", border: "1px solid var(--line-faint)", }} title={t("template.source.tooltip", { label: def.example.source.label })} > ↗ {def.example.source.label} )} {hasPreview && ( )} {isSelected && }
); })}
)) )}
{/* footer hint */}
{t("template.footer.hint.search")} ·{" "} {t("template.footer.hint.filter")} ·{" "} {t("template.footer.hint.hover")} ·{" "} {t("template.footer.hint.preview")} Esc {t("template.footer.esc")}
)} {/* hover preview popover — appears next to dropdown when a row with example.html is hovered */} {open && hoveredTpl && hoveredTpl.example?.hasHtml && (