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.
This commit is contained in:
2026-09-17 19:36:30 -05:00
parent bbb33905c5
commit f74cf323b5
3 changed files with 304 additions and 75 deletions
+5 -2
View File
@@ -69,7 +69,9 @@ they land.
blacklist-aware) and deleted-pool markers
- [x] Pool detail: DText description, posts kept in pool order with chunked
loading and in-library badges, blacklist reveal toggle, Follow button
- [ ] `F` shortcut to favorite a post, tag finder in the Ctrl+K palette
- [x] `F` shortcut to favorite a post (online detail), tag finder in the Ctrl+K
palette (e621 tag suggestions with follow toggles, recent searches, and
"search e621 for …")
## 3. Uploads & ingestion pipeline
@@ -116,7 +118,8 @@ Files now stage first and are resolved before entering the library.
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
(self-service picker in Account still pending)
- [ ] Profile extras (per-user browse preferences)
- [ ] Command palette: tag finder, recent searches
- [x] Command palette: tag finder, recent searches, navigation commands
(Ctrl+K / ⌘K, reachable from anywhere in the shell)
## 6. Infrastructure
+284 -72
View File
@@ -1,24 +1,65 @@
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 { useMemo, useState } from "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;
@@ -27,6 +68,12 @@ interface Command {
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,
@@ -43,8 +90,26 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
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[] = [
@@ -54,6 +119,34 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
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
? [
{
@@ -76,12 +169,6 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
},
]
: []),
{
id: "online",
label: "Go to Online browser",
icon: Globe,
run: () => navigate("/online"),
},
{
id: "filters",
label: "Toggle filters",
@@ -134,19 +221,124 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
return list;
}, [navigate, toggleSidebar, user, logout]);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
return needle
? commands.filter((command) =>
command.label.toLowerCase().includes(needle),
)
: commands;
}, [commands, query]);
const needle = query.trim().toLowerCase();
function runCommand(command: Command | undefined) {
if (!command) return;
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();
command.run();
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 (
@@ -158,63 +350,83 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
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()}
>
<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, filtered.length - 1),
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => Math.max(index - 1, 0));
} else if (event.key === "Enter") {
event.preventDefault();
runCommand(filtered[activeIndex]);
} else if (event.key === "Escape") {
onClose();
}
}}
placeholder="Type a command…"
className="w-full border-b border-ctp-surface1 bg-transparent px-4 py-3 font-mono text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:outline-none"
/>
<ul className="max-h-72 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-ctp-overlay0">
No matching commands
</li>
<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>
) : (
filtered.map((command, index) => (
<li key={command.id}>
<button
type="button"
onMouseEnter={() => setActiveIndex(index)}
onClick={() => runCommand(command)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition",
index === activeIndex
? "bg-ctp-surface1 text-ctp-text"
: "text-ctp-subtext0",
)}
>
<command.icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{command.label}</span>
{command.hint ? (
<span className="font-mono text-[11px] text-ctp-overlay0">
{command.hint}
</span>
) : null}
</button>
</li>
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>
))
)}
</ul>
</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>
);
+15 -1
View File
@@ -16,6 +16,7 @@ import { DetailShell } from "@/features/detail/DetailShell";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import { isTypingTarget } from "@/lib/dom";
import {
e621ErrorMessage,
effectiveCredentials,
@@ -118,6 +119,19 @@ export function OnlineDetail({ postId }: { postId: number }) {
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
if (event.key.toLowerCase() !== "f") return;
if (!post || !credentials?.configured || favoriteMutation.isPending) return;
event.preventDefault();
favoriteMutation.mutate(!post.is_favorited);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [post, credentials, favoriteMutation]);
const downloadMutation = useMutation({
mutationFn: (target: NonNullable<typeof post>) =>
api<DownloadTask>("/api/online/downloads/", {
@@ -234,7 +248,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
title={
credentials?.configured
? undefined
? "Toggle favorite (F)"
: "Add e621 credentials in Account to favorite posts"
}
>