From f74cf323b55a1a8838120f418342ceef8a9412e2 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 19:36:30 -0500 Subject: [PATCH] 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. --- ROADMAP.md | 7 +- frontend/src/components/CommandPalette.tsx | 356 ++++++++++++++---- frontend/src/features/online/OnlineDetail.tsx | 16 +- 3 files changed, 304 insertions(+), 75 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f69e492..061df7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index cf6d849..70ebb99 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -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(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 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.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 + + + event.stopPropagation()} + > + + + + ); } 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()} > - { - 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" - /> -
    - {filtered.length === 0 ? ( -
  • - No matching commands -
  • +
    + + { + 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"} +

    ) : ( - filtered.map((command, index) => ( -
  • - -
  • + 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 · + follows it + + ↑↓ · Esc +
); diff --git a/frontend/src/features/online/OnlineDetail.tsx b/frontend/src/features/online/OnlineDetail.tsx index 26e07c9..cd9989c 100644 --- a/frontend/src/features/online/OnlineDetail.tsx +++ b/frontend/src/features/online/OnlineDetail.tsx @@ -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(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) => api("/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" } >