Files
J621/frontend/src/components/CommandPalette.tsx
T
JakeBreath f74cf323b5 Add the F favorite shortcut and the Ctrl+K tag finder
The command palette now searches e621 tags as you type (debounced
/tags.json suggestions with category chips and post counts), lets you
search a tag online, follow/unfollow it inline and open it on e621, and
remembers recent searches in localStorage. New navigation commands cover
Pools, Followed and Similar. On the online detail, F toggles the current
post's favorite.
2026-09-17 19:36:30 -05:00

434 lines
12 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import {
Copy,
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 { 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 <CommandPaletteDialog onClose={onClose} />;
}
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<string[]>(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<Command[]>(() => {
const list: Command[] = [
{
id: "library",
label: "Go to Library",
icon: FolderOpen,
run: () => navigate("/"),
},
{
id: "online",
label: "Go to Online browser",
icon: Globe,
run: () => navigate("/online"),
},
{
id: "pools",
label: "Go to Pools",
icon: Layers,
run: () => navigate("/pools"),
},
...(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"),
},
]
: []),
...(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,
},
{
id: "search",
label: "Focus library search",
hint: "/",
icon: Search,
run: () => {
navigate("/");
window.requestAnimationFrame(() => {
document.getElementById("library-search")?.focus();
});
},
},
];
if (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 {
list.push({
id: "login",
label: "Log in",
icon: LogIn,
run: () => navigate("/login"),
});
}
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.icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{row.command.label}</span>
{row.command.hint ? (
<span className="font-mono text-[11px] text-ctp-overlay0">
{row.command.hint}
</span>
) : null}
</>
);
}
if (row.kind === "search") {
return (
<>
<Globe className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">
Search e621 for “{row.query}”
</span>
</>
);
}
if (row.kind === "recent") {
return (
<>
<History className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate font-mono text-xs">{row.tag}</span>
</>
);
}
return (
<>
<TagChip tag={row.tag.name} category={tagCategoryName(row.tag.category)} />
<span className="ml-auto font-mono text-[11px] text-ctp-overlay0">
{row.tag.post_count.toLocaleString()} posts
</span>
<TagFollowToggle tag={row.tag.name} />
<a
href={`https://e621.net/posts?tags=${encodeURIComponent(row.tag.name)}`}
target="_blank"
rel="noreferrer"
title="Open on e621"
className="text-ctp-overlay0 transition hover:text-ctp-blue"
onClick={(event) => event.stopPropagation()}
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</>
);
}
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-ctp-crust/70 px-4 pt-[18vh] backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-lg overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-surface0 shadow-[0_4px_12px_rgba(17,17,27,0.6)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-ctp-surface1 px-4">
<Search className="h-4 w-4 shrink-0 text-ctp-overlay0" />
<input
autoFocus
value={query}
onChange={(event) => {
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 ? (
<Spinner className="h-3.5 w-3.5 shrink-0" />
) : null}
</div>
<div className="max-h-80 overflow-y-auto p-1.5" role="listbox">
{flat.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-ctp-overlay0">
{needle ? "No matching commands or tags" : "Type to search"}
</p>
) : (
sections.map((section) => (
<div key={section.title}>
<p className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-ctp-overlay0">
{section.title}
</p>
{section.rows.map((row) => {
const index = flat.indexOf(row);
const active = index === safeIndex;
return (
<div
key={row.key}
role="option"
aria-selected={active}
onMouseEnter={() => 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)}
</div>
);
})}
</div>
))
)}
</div>
<div className="flex items-center gap-3 border-t border-ctp-surface1 px-4 py-2 text-[10px] text-ctp-overlay0">
<span className="flex items-center gap-1.5">
<Images className="h-3 w-3 shrink-0" /> Enter searches the
highlighted tag · + follows it
</span>
<span className="ml-auto font-mono">↑↓ · Esc</span>
</div>
</div>
</div>
);
}