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.
434 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|