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
|
blacklist-aware) and deleted-pool markers
|
||||||
- [x] Pool detail: DText description, posts kept in pool order with chunked
|
- [x] Pool detail: DText description, posts kept in pool order with chunked
|
||||||
loading and in-library badges, blacklist reveal toggle, Follow button
|
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
|
## 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
|
- [x] Profile pictures: staff Users page sets avatars from library J-IDs
|
||||||
(self-service picker in Account still pending)
|
(self-service picker in Account still pending)
|
||||||
- [ ] Profile extras (per-user browse preferences)
|
- [ ] 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
|
## 6. Infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,65 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Globe,
|
Globe,
|
||||||
|
History,
|
||||||
|
Images,
|
||||||
|
Layers,
|
||||||
LogIn,
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
|
ScanSearch,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { cn } from "@/lib/cn";
|
||||||
|
import {
|
||||||
|
effectiveCredentials,
|
||||||
|
fetchTagSuggestions,
|
||||||
|
tagCategoryName,
|
||||||
|
type E621Tag,
|
||||||
|
} from "@/lib/e621";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useE621 } from "@/store/e621";
|
||||||
import { useUi } from "@/store/ui";
|
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 {
|
interface Command {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -27,6 +68,12 @@ interface Command {
|
|||||||
run: () => void;
|
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({
|
export function CommandPalette({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -43,8 +90,26 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const logout = useAuth((state) => state.logout);
|
const logout = useAuth((state) => state.logout);
|
||||||
const toggleSidebar = useUi((state) => state.toggleSidebar);
|
const toggleSidebar = useUi((state) => state.toggleSidebar);
|
||||||
|
const credentials = useE621((state) => state.credentials);
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [debounced, setDebounced] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
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 commands = useMemo<Command[]>(() => {
|
||||||
const list: Command[] = [
|
const list: Command[] = [
|
||||||
@@ -54,6 +119,34 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
run: () => navigate("/"),
|
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
|
...(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",
|
id: "filters",
|
||||||
label: "Toggle filters",
|
label: "Toggle filters",
|
||||||
@@ -134,19 +221,124 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
return list;
|
return list;
|
||||||
}, [navigate, toggleSidebar, user, logout]);
|
}, [navigate, toggleSidebar, user, logout]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
return needle
|
|
||||||
? commands.filter((command) =>
|
|
||||||
command.label.toLowerCase().includes(needle),
|
|
||||||
)
|
|
||||||
: commands;
|
|
||||||
}, [commands, query]);
|
|
||||||
|
|
||||||
function runCommand(command: Command | undefined) {
|
const sections = useMemo(() => {
|
||||||
if (!command) return;
|
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();
|
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 (
|
return (
|
||||||
@@ -158,6 +350,8 @@ 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)]"
|
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()}
|
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
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
@@ -169,52 +363,70 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setActiveIndex((index) =>
|
setActiveIndex((index) =>
|
||||||
Math.min(index + 1, filtered.length - 1),
|
Math.min(index + 1, Math.max(0, flat.length - 1)),
|
||||||
);
|
);
|
||||||
} else if (event.key === "ArrowUp") {
|
} else if (event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setActiveIndex((index) => Math.max(index - 1, 0));
|
setActiveIndex((index) => Math.max(index - 1, 0));
|
||||||
} else if (event.key === "Enter") {
|
} else if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
runCommand(filtered[activeIndex]);
|
runRow(flat[safeIndex]);
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Type a command…"
|
placeholder="Search tags or run 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"
|
className="w-full bg-transparent 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">
|
{tagsQuery.isFetching ? (
|
||||||
{filtered.length === 0 ? (
|
<Spinner className="h-3.5 w-3.5 shrink-0" />
|
||||||
<li className="px-3 py-6 text-center text-sm text-ctp-overlay0">
|
) : null}
|
||||||
No matching commands
|
</div>
|
||||||
</li>
|
|
||||||
|
<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) => (
|
sections.map((section) => (
|
||||||
<li key={command.id}>
|
<div key={section.title}>
|
||||||
<button
|
<p className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-ctp-overlay0">
|
||||||
type="button"
|
{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)}
|
onMouseEnter={() => setActiveIndex(index)}
|
||||||
onClick={() => runCommand(command)}
|
onClick={() => runRow(row)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition",
|
"flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition",
|
||||||
index === activeIndex
|
active
|
||||||
? "bg-ctp-surface1 text-ctp-text"
|
? "bg-ctp-surface1 text-ctp-text"
|
||||||
: "text-ctp-subtext0",
|
: "text-ctp-subtext0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<command.icon className="h-4 w-4 shrink-0" />
|
{rowContent(row)}
|
||||||
<span className="flex-1 truncate">{command.label}</span>
|
</div>
|
||||||
{command.hint ? (
|
);
|
||||||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
})}
|
||||||
{command.hint}
|
</div>
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { DetailShell } from "@/features/detail/DetailShell";
|
|||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { renderDText } from "@/lib/dtext";
|
import { renderDText } from "@/lib/dtext";
|
||||||
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
import {
|
import {
|
||||||
e621ErrorMessage,
|
e621ErrorMessage,
|
||||||
effectiveCredentials,
|
effectiveCredentials,
|
||||||
@@ -118,6 +119,19 @@ export function OnlineDetail({ postId }: { postId: number }) {
|
|||||||
|
|
||||||
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
|
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({
|
const downloadMutation = useMutation({
|
||||||
mutationFn: (target: NonNullable<typeof post>) =>
|
mutationFn: (target: NonNullable<typeof post>) =>
|
||||||
api<DownloadTask>("/api/online/downloads/", {
|
api<DownloadTask>("/api/online/downloads/", {
|
||||||
@@ -234,7 +248,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
|
|||||||
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
||||||
title={
|
title={
|
||||||
credentials?.configured
|
credentials?.configured
|
||||||
? undefined
|
? "Toggle favorite (F)"
|
||||||
: "Add e621 credentials in Account to favorite posts"
|
: "Add e621 credentials in Account to favorite posts"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user