"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { SIDEBAR_SECTIONS, HIDDEN_SIDEBAR_ITEMS_SETTING_KEY, normalizeHiddenSidebarItems, type SidebarItemDefinition, type SidebarSectionChild, } from "@/shared/constants/sidebarVisibility"; function isSidebarGroup( child: SidebarSectionChild ): child is Extract { return "type" in child && child.type === "group"; } interface CommandPaletteProps { isOpen: boolean; onClose: () => void; } export default function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { if (!isOpen) return null; return ; } interface PaletteItem { id: string; href: string; icon: string; label: string; subtitle?: string; external: boolean; sectionId: string; sectionLabel: string; subgroupId?: string; subgroupLabel?: string; } interface PaletteSubgroup { subgroupId: string | null; subgroupLabel: string | null; items: { item: PaletteItem; flatIndex: number }[]; } interface PaletteGroup { sectionId: string; sectionLabel: string; subgroups: PaletteSubgroup[]; } function CommandPaletteDialog({ onClose }: { onClose: () => void }) { const router = useRouter(); const t = useTranslations("sidebar"); const inputRef = useRef(null); const listRef = useRef(null); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [hiddenItems, setHiddenItems] = useState>(new Set()); useEffect(() => { const ctrl = new AbortController(); fetch("/api/settings", { signal: ctrl.signal }) .then((res) => res.json()) .then((data) => { setHiddenItems( new Set(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])) ); }) .catch(() => { // ignore aborts and fetch failures; palette still works with empty hidden set }); return () => ctrl.abort(); }, []); useEffect(() => { const id = setTimeout(() => inputRef.current?.focus(), 30); return () => clearTimeout(id); }, []); const safeTranslate = useCallback( (key: string, fallback: string) => { try { return t(key); } catch { return fallback; } }, [t] ); const allItems = useMemo( () => SIDEBAR_SECTIONS.flatMap((section) => { const sectionLabel = safeTranslate(section.titleKey, section.titleFallback); return section.children.flatMap((child) => { if (isSidebarGroup(child)) { const subgroupLabel = safeTranslate(child.titleKey, child.titleFallback); return child.items .filter((item) => !hiddenItems.has(item.id)) .map((item) => ({ id: item.id, href: item.href, icon: item.icon, label: safeTranslate(item.i18nKey, item.id), subtitle: item.subtitleKey ? safeTranslate(item.subtitleKey, "") : undefined, external: item.external ?? false, sectionId: section.id, sectionLabel, subgroupId: child.id, subgroupLabel, })); } const item = child as SidebarItemDefinition; if (hiddenItems.has(item.id)) return []; return [ { id: item.id, href: item.href, icon: item.icon, label: safeTranslate(item.i18nKey, item.id), subtitle: item.subtitleKey ? safeTranslate(item.subtitleKey, "") : undefined, external: item.external ?? false, sectionId: section.id, sectionLabel, }, ]; }); }), [hiddenItems, safeTranslate] ); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return allItems; return allItems.filter( (item) => item.label.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q) || item.sectionLabel.toLowerCase().includes(q) || item.subgroupLabel?.toLowerCase().includes(q) ); }, [allItems, query]); const grouped = useMemo(() => { const groups: PaletteGroup[] = []; filtered.forEach((item, flatIndex) => { let section = groups[groups.length - 1]; if (!section || section.sectionId !== item.sectionId) { section = { sectionId: item.sectionId, sectionLabel: item.sectionLabel, subgroups: [], }; groups.push(section); } const itemSubgroupId = item.subgroupId ?? null; let subgroup = section.subgroups[section.subgroups.length - 1]; if (!subgroup || subgroup.subgroupId !== itemSubgroupId) { subgroup = { subgroupId: itemSubgroupId, subgroupLabel: item.subgroupLabel ?? null, items: [], }; section.subgroups.push(subgroup); } subgroup.items.push({ item, flatIndex }); }); return groups; }, [filtered]); const handleNavigate = useCallback( (href: string, external: boolean) => { onClose(); if (external) { window.open(href, "_blank", "noopener,noreferrer"); } else { router.push(href); } }, [onClose, router] ); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); return; } if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % Math.max(1, filtered.length)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex( (prev) => (prev - 1 + Math.max(1, filtered.length)) % Math.max(1, filtered.length) ); } else if (e.key === "Enter") { e.preventDefault(); const item = filtered[selectedIndex]; if (item) { handleNavigate(item.href, item.external); } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [filtered, selectedIndex, onClose, handleNavigate]); useEffect(() => { const list = listRef.current; if (!list) return; const el = list.querySelector(`[data-flat-index="${selectedIndex}"]`); el?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); return (
); }