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:
+5
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user