import { useQuery } from "@tanstack/react-query"; import { Cable, Copy, Dices, ExternalLink, FolderOpen, Globe, History, Images, Layers, LogIn, LogOut, Menu, ScanSearch, Search, Settings, Star, Trash2, Upload, Users, type LucideIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { TagChip } from "@/components/TagChip"; import { Spinner } from "@/components/ui"; import { TagFollowToggle } from "@/features/follows/TagFollowToggle"; import { hasBackend } from "@/lib/backend"; import { cn } from "@/lib/cn"; import { effectiveCredentials, fetchTagSuggestions, tagCategoryName, type E621Tag, } from "@/lib/e621"; import { useAuth } from "@/store/auth"; import { useE621 } from "@/store/e621"; import { useUi } from "@/store/ui"; const RECENT_KEY = "j621.palette.recent-tags"; const RECENT_LIMIT = 6; function readRecentTags(): string[] { try { const raw = window.localStorage.getItem(RECENT_KEY); const parsed = raw ? (JSON.parse(raw) as unknown) : []; if (!Array.isArray(parsed)) return []; return parsed .filter((value): value is string => typeof value === "string") .slice(0, RECENT_LIMIT); } catch { return []; } } function writeRecentTags(tags: string[]): void { try { window.localStorage.setItem(RECENT_KEY, JSON.stringify(tags)); } catch { // Storage disabled: recents just won't persist. } } interface Command { id: string; label: string; hint?: string; icon: LucideIcon; run: () => void; } type Row = | { kind: "command"; key: string; command: Command } | { kind: "tag"; key: string; tag: E621Tag } | { kind: "recent"; key: string; tag: string } | { kind: "search"; key: string; query: string }; export function CommandPalette({ open, onClose, }: { open: boolean; onClose: () => void; }) { if (!open) return null; return ; } function CommandPaletteDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); const user = useAuth((state) => state.user); const logout = useAuth((state) => state.logout); const toggleSidebar = useUi((state) => state.toggleSidebar); const credentials = useE621((state) => state.credentials); const [query, setQuery] = useState(""); const [debounced, setDebounced] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const [recent, setRecent] = useState(readRecentTags); useEffect(() => { const timer = window.setTimeout(() => setDebounced(query.trim()), 220); return () => window.clearTimeout(timer); }, [query]); const tagsQuery = useQuery({ queryKey: ["palette-tags", debounced, credentials?.username ?? ""], queryFn: ({ signal }) => fetchTagSuggestions(effectiveCredentials(credentials), debounced, signal), enabled: debounced.length >= 2 && !debounced.includes(" "), staleTime: 60_000, retry: 0, }); const commands = useMemo(() => { const backend = hasBackend(); const list: Command[] = [ ...(backend ? [ { id: "library", label: "Go to Library", icon: FolderOpen, run: () => navigate("/"), }, { id: "random", label: "Roll a random image", icon: Dices, run: () => navigate("/random"), }, ] : []), { id: "online", label: "Go to Online browser", icon: Globe, run: () => navigate("/online"), }, { id: "pools", label: "Go to Pools", icon: Layers, run: () => navigate("/pools"), }, ...(backend && user ? [ { id: "followed", label: "Go to Followed", icon: Star, run: () => navigate("/followed"), }, { id: "similar", label: "Check an image for similarity", icon: ScanSearch, run: () => navigate("/similar"), }, ] : []), ...(backend && user?.can_upload ? [ { id: "upload", label: "Go to Upload", icon: Upload, run: () => navigate("/upload"), }, { id: "duplicates", label: "Find duplicates", icon: Copy, run: () => navigate("/duplicates"), }, { id: "delete", label: "Delete files & storage", icon: Trash2, run: () => navigate("/delete"), }, ] : []), { id: "filters", label: "Toggle filters", hint: "Ctrl+B", icon: Menu, run: toggleSidebar, }, ...(backend ? [ { id: "search", label: "Focus library search", hint: "/", icon: Search, run: () => { navigate("/"); window.requestAnimationFrame(() => { document.getElementById("library-search")?.focus(); }); }, }, ] : []), { id: "setup", label: backend ? "Change backend" : "Set up a backend", icon: Cable, run: () => navigate("/setup"), }, ]; if (backend && user) { list.push({ id: "account", label: "Account settings", icon: Settings, run: () => navigate("/account"), }); if (user.is_staff || user.is_superuser || user.role === "staff") { list.push({ id: "users", label: "Manage users", icon: Users, run: () => navigate("/users"), }); } list.push({ id: "logout", label: `Log out (${user.username})`, icon: LogOut, run: () => void logout(), }); } else if (backend) { list.push({ id: "login", label: "Log in", icon: LogIn, run: () => navigate("/login"), }); } else { list.push({ id: "credentials", label: "e621 credentials", icon: Settings, run: () => navigate("/account"), }); } return list; }, [navigate, toggleSidebar, user, logout]); const needle = query.trim().toLowerCase(); const sections = useMemo(() => { const searchRows: Row[] = needle ? [{ kind: "search", key: "search", query: query.trim() }] : []; const tagRows: Row[] = (tagsQuery.data ?? []).map((tag) => ({ kind: "tag", key: `tag-${tag.id}`, tag, })); const recentRows: Row[] = !needle ? recent.map((tag) => ({ kind: "recent", key: `recent-${tag}`, tag })) : []; const commandRows: Row[] = commands .filter( (command) => !needle || command.label.toLowerCase().includes(needle), ) .map((command) => ({ kind: "command", key: `cmd-${command.id}`, command, })); return [ { title: "Actions", rows: searchRows }, { title: "Tags", rows: tagRows }, { title: "Recent searches", rows: recentRows }, { title: "Commands", rows: commandRows }, ].filter((section) => section.rows.length > 0); }, [needle, query, recent, commands, tagsQuery.data]); const flat = useMemo(() => sections.flatMap((section) => section.rows), [ sections, ]); const safeIndex = Math.min(activeIndex, Math.max(0, flat.length - 1)); function searchTag(value: string) { const tag = value.trim(); if (!tag) return; const next = [tag, ...recent.filter((entry) => entry !== tag)].slice( 0, RECENT_LIMIT, ); setRecent(next); writeRecentTags(next); onClose(); navigate(`/online?tags=${encodeURIComponent(tag)}`); } function runRow(row: Row | undefined) { if (!row) return; if (row.kind === "command") { onClose(); row.command.run(); return; } if (row.kind === "tag") { searchTag(row.tag.name); return; } if (row.kind === "recent") { searchTag(row.tag); return; } searchTag(row.query); } function rowContent(row: Row) { if (row.kind === "command") { return ( <> {row.command.label} {row.command.hint ? ( {row.command.hint} ) : null} ); } if (row.kind === "search") { return ( <> Search e621 for “{row.query}” ); } if (row.kind === "recent") { return ( <> {row.tag} ); } return ( <> {row.tag.post_count.toLocaleString()} posts {hasBackend() ? : null} event.stopPropagation()} > ); } return (
event.stopPropagation()} >
{ setQuery(event.target.value); setActiveIndex(0); }} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((index) => Math.min(index + 1, Math.max(0, flat.length - 1)), ); } else if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => Math.max(index - 1, 0)); } else if (event.key === "Enter") { event.preventDefault(); runRow(flat[safeIndex]); } else if (event.key === "Escape") { onClose(); } }} placeholder="Search tags or run a command…" className="w-full bg-transparent py-3 font-mono text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:outline-none" /> {tagsQuery.isFetching ? ( ) : null}
{flat.length === 0 ? (

{needle ? "No matching commands or tags" : "Type to search"}

) : ( sections.map((section) => (

{section.title}

{section.rows.map((row) => { const index = flat.indexOf(row); const active = index === safeIndex; return (
setActiveIndex(index)} onClick={() => runRow(row)} className={cn( "flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition", active ? "bg-ctp-surface1 text-ctp-text" : "text-ctp-subtext0", )} > {rowContent(row)}
); })}
)) )}
Enter searches the highlighted tag — the + button only follows/unfollows ↑↓ · Esc
); }