| "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<SidebarSectionChild, { type: "group" }> {
|
| 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 <CommandPaletteDialog onClose={onClose} />;
|
| }
|
|
|
| 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<HTMLInputElement>(null);
|
| const listRef = useRef<HTMLUListElement>(null);
|
| const [query, setQuery] = useState("");
|
| const [selectedIndex, setSelectedIndex] = useState(0);
|
| const [hiddenItems, setHiddenItems] = useState<Set<string>>(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(() => {
|
|
|
| });
|
| 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<PaletteItem[]>(
|
| () =>
|
| SIDEBAR_SECTIONS.flatMap((section) => {
|
| const sectionLabel = safeTranslate(section.titleKey, section.titleFallback);
|
| return section.children.flatMap<PaletteItem>((child) => {
|
| if (isSidebarGroup(child)) {
|
| const subgroupLabel = safeTranslate(child.titleKey, child.titleFallback);
|
| return child.items
|
| .filter((item) => !hiddenItems.has(item.id))
|
| .map<PaletteItem>((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<PaletteGroup[]>(() => {
|
| 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<HTMLElement>(`[data-flat-index="${selectedIndex}"]`);
|
| el?.scrollIntoView({ block: "nearest" });
|
| }, [selectedIndex]);
|
|
|
| return (
|
| <div className="fixed inset-0 z-[60] flex items-start justify-center pt-[10vh] px-4">
|
| <div
|
| className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
| onClick={onClose}
|
| aria-hidden="true"
|
| />
|
| <div
|
| className="relative w-full max-w-3xl bg-surface border border-black/10 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
| role="dialog"
|
| aria-modal="true"
|
| aria-label="Command palette"
|
| >
|
| <div className="flex items-center gap-3 px-6 py-4 border-b border-black/5 dark:border-white/5">
|
| <span className="material-symbols-outlined text-[20px] text-text-muted shrink-0">
|
| search
|
| </span>
|
| <input
|
| ref={inputRef}
|
| type="text"
|
| className="flex-1 bg-transparent text-text placeholder:text-text-muted outline-none text-base"
|
| placeholder="Search pages, settings, tools..."
|
| value={query}
|
| onChange={(e) => {
|
| setQuery(e.target.value);
|
| setSelectedIndex(0);
|
| }}
|
| autoComplete="off"
|
| spellCheck={false}
|
| />
|
| {query && (
|
| <button
|
| className="text-text-muted hover:text-text transition-colors"
|
| onClick={() => {
|
| setQuery("");
|
| setSelectedIndex(0);
|
| }}
|
| tabIndex={-1}
|
| aria-label="Clear search"
|
| >
|
| <span className="material-symbols-outlined text-[16px]">close</span>
|
| </button>
|
| )}
|
| <kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-mono bg-black/5 dark:bg-white/5 text-text-muted border border-black/10 dark:border-white/10 shrink-0">
|
| Esc
|
| </kbd>
|
| </div>
|
|
|
| {grouped.length > 0 ? (
|
| <ul
|
| ref={listRef}
|
| className="py-2 max-h-[60vh] overflow-y-auto custom-scrollbar"
|
| role="listbox"
|
| >
|
| {grouped.map((group) => (
|
| <li key={group.sectionId} role="presentation">
|
| <div className="sticky top-0 z-10 bg-surface/95 backdrop-blur-sm px-6 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-text-muted border-b border-black/5 dark:border-white/5">
|
| {group.sectionLabel}
|
| </div>
|
| <ul role="group" aria-label={group.sectionLabel}>
|
| {group.subgroups.map((subgroup) => (
|
| <li
|
| key={`${group.sectionId}::${subgroup.subgroupId ?? "_root"}`}
|
| role="presentation"
|
| >
|
| {subgroup.subgroupLabel && (
|
| <div className="px-6 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-text-muted/70">
|
| {subgroup.subgroupLabel}
|
| </div>
|
| )}
|
| <ul
|
| role={subgroup.subgroupLabel ? "group" : "presentation"}
|
| aria-label={subgroup.subgroupLabel ?? undefined}
|
| >
|
| {subgroup.items.map(({ item, flatIndex }) => (
|
| <li
|
| key={item.id}
|
| role="option"
|
| aria-selected={flatIndex === selectedIndex}
|
| data-flat-index={flatIndex}
|
| >
|
| <button
|
| className={`w-full flex items-center gap-3 ${
|
| subgroup.subgroupLabel ? "pl-10 pr-6" : "px-6"
|
| } py-2.5 text-left transition-colors ${
|
| flatIndex === selectedIndex
|
| ? "bg-accent/10 text-accent ring-1 ring-inset ring-accent/20"
|
| : "text-text hover:bg-black/5 dark:hover:bg-white/5"
|
| }`}
|
| onClick={() => handleNavigate(item.href, item.external)}
|
| onMouseEnter={() => setSelectedIndex(flatIndex)}
|
| >
|
| <span
|
| className={`material-symbols-outlined text-[18px] shrink-0 ${
|
| flatIndex === selectedIndex ? "text-accent" : "text-text-muted"
|
| }`}
|
| >
|
| {item.icon}
|
| </span>
|
| <div className="flex-1 min-w-0">
|
| <p className="text-sm font-medium truncate">{item.label}</p>
|
| {item.subtitle && (
|
| <p
|
| className={`text-xs truncate ${
|
| flatIndex === selectedIndex
|
| ? "text-accent/70"
|
| : "text-text-muted"
|
| }`}
|
| >
|
| {item.subtitle}
|
| </p>
|
| )}
|
| </div>
|
| {item.external && (
|
| <span className="material-symbols-outlined text-[14px] text-text-muted shrink-0">
|
| open_in_new
|
| </span>
|
| )}
|
| </button>
|
| </li>
|
| ))}
|
| </ul>
|
| </li>
|
| ))}
|
| </ul>
|
| </li>
|
| ))}
|
| </ul>
|
| ) : (
|
| <div className="py-10 text-center text-text-muted text-sm">No results</div>
|
| )}
|
|
|
| <div className="flex items-center gap-4 px-4 py-2 border-t border-black/5 dark:border-white/5 text-[11px] text-text-muted">
|
| <span className="flex items-center gap-1">
|
| <kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
|
| ↑↓
|
| </kbd>
|
| navigate
|
| </span>
|
| <span className="flex items-center gap-1">
|
| <kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
|
| ↵
|
| </kbd>
|
| open
|
| </span>
|
| <span className="flex items-center gap-1">
|
| <kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
|
| Esc
|
| </kbd>
|
| close
|
| </span>
|
| </div>
|
| </div>
|
| </div>
|
| );
|
| }
|
|
|