diff --git a/frontend/src/components/CollapsibleSidebar.tsx b/frontend/src/components/CollapsibleSidebar.tsx
new file mode 100644
index 0000000..87d6285
--- /dev/null
+++ b/frontend/src/components/CollapsibleSidebar.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from "react";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/cn";
+import { useUi } from "@/store/ui";
+
+export function CollapsibleSidebar({
+ title = "Filters",
+ children,
+}: {
+ title?: string;
+ children: ReactNode;
+}) {
+ const sidebarOpen = useUi((state) => state.sidebarOpen);
+ const setSidebarOpen = useUi((state) => state.setSidebarOpen);
+
+ if (!sidebarOpen) return null;
+
+ return (
+ <>
+
setSidebarOpen(false)}
+ aria-hidden="true"
+ />
+
+ >
+ );
+}
diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx
index c0d75eb..a65eba7 100644
--- a/frontend/src/components/CommandPalette.tsx
+++ b/frontend/src/components/CommandPalette.tsx
@@ -1,5 +1,6 @@
import {
FolderOpen,
+ Globe,
LogIn,
LogOut,
Menu,
@@ -56,6 +57,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Upload,
run: () => navigate("/upload"),
},
+ {
+ id: "online",
+ label: "Go to Online browser",
+ icon: Globe,
+ run: () => navigate("/online"),
+ },
{
id: "filters",
label: "Toggle filters",
diff --git a/frontend/src/components/TagChip.tsx b/frontend/src/components/TagChip.tsx
new file mode 100644
index 0000000..0866aa2
--- /dev/null
+++ b/frontend/src/components/TagChip.tsx
@@ -0,0 +1,49 @@
+import { cn } from "@/lib/cn";
+
+const TAG_STYLES: Record
= {
+ general:
+ "border-ctp-blue/30 bg-ctp-blue/10 text-ctp-blue hover:bg-ctp-blue/20",
+ artist:
+ "border-ctp-yellow/30 bg-ctp-yellow/10 text-ctp-yellow hover:bg-ctp-yellow/20",
+ copyright:
+ "border-ctp-mauve/30 bg-ctp-mauve/10 text-ctp-mauve hover:bg-ctp-mauve/20",
+ character:
+ "border-ctp-green/30 bg-ctp-green/10 text-ctp-green hover:bg-ctp-green/20",
+ species:
+ "border-ctp-peach/30 bg-ctp-peach/10 text-ctp-peach hover:bg-ctp-peach/20",
+ meta: "border-ctp-subtext0/30 bg-ctp-subtext0/10 text-ctp-subtext0 hover:bg-ctp-subtext0/20",
+ lore: "border-ctp-teal/30 bg-ctp-teal/10 text-ctp-teal hover:bg-ctp-teal/20",
+ invalid:
+ "border-ctp-red/30 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20",
+ contributor:
+ "border-ctp-lavender/30 bg-ctp-lavender/10 text-ctp-lavender hover:bg-ctp-lavender/20",
+ unknown:
+ "border-ctp-surface1 text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text",
+};
+
+export function TagChip({
+ tag,
+ category,
+ count,
+ onClick,
+}: {
+ tag: string;
+ category?: string;
+ count?: number;
+ onClick?: () => void;
+}) {
+ const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown;
+ return (
+
+ );
+}
diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx
index b238a0f..864b6c3 100644
--- a/frontend/src/features/library/LibraryPage.tsx
+++ b/frontend/src/features/library/LibraryPage.tsx
@@ -1,7 +1,8 @@
import { useQuery } from "@tanstack/react-query";
-import { Search, X } from "lucide-react";
+import { Search } from "lucide-react";
import { useState, useEffect, type FormEvent } from "react";
+import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
import { MediaCard } from "@/components/MediaCard";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api } from "@/lib/api";
@@ -44,8 +45,6 @@ export default function LibraryPage() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
- const sidebarOpen = useUi((state) => state.sidebarOpen);
- const setSidebarOpen = useUi((state) => state.setSidebarOpen);
const ratings = useUi((state) => state.ratings);
const setRatings = useUi((state) => state.setRatings);
const perPage = useUi((state) => state.perPage);
@@ -199,36 +198,7 @@ export default function LibraryPage() {
return (
- {sidebarOpen ? (
- <>
-
setSidebarOpen(false)}
- aria-hidden="true"
- />
-
- >
- ) : null}
+
{filterPanel}
diff --git a/frontend/src/features/online/OnlineDetailPage.tsx b/frontend/src/features/online/OnlineDetailPage.tsx
new file mode 100644
index 0000000..822b81c
--- /dev/null
+++ b/frontend/src/features/online/OnlineDetailPage.tsx
@@ -0,0 +1,393 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ CheckCircle2,
+ Download,
+ ExternalLink,
+ Heart,
+ HeartOff,
+} from "lucide-react";
+import { useMemo } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+
+import { Button, EmptyState, Spinner } from "@/components/ui";
+import { TagChip } from "@/components/TagChip";
+import { api } from "@/lib/api";
+import { cn } from "@/lib/cn";
+import {
+ e621ErrorMessage,
+ effectiveCredentials,
+ fetchPost,
+ fetchPostsByIds,
+ RATING_LABELS,
+ setFavorite,
+ TAG_CATEGORY_LABELS,
+ TAG_CATEGORY_ORDER,
+} from "@/lib/e621";
+import { formatBytes, formatDate } from "@/lib/format";
+import { useE621 } from "@/store/e621";
+
+const ratingText: Record
= {
+ s: "text-ctp-green",
+ q: "text-ctp-peach",
+ e: "text-ctp-red",
+};
+
+const relatedBorder: Record = {
+ s: "border-ctp-green/25 hover:border-ctp-green/60",
+ q: "border-ctp-peach/25 hover:border-ctp-peach/60",
+ e: "border-ctp-red/25 hover:border-ctp-red/60",
+};
+
+const linkButtonClass =
+ "inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
+
+export default function OnlineDetailPage() {
+ const { id = "" } = useParams();
+ const postId = Number(id);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const credentials = useE621((state) => state.credentials);
+
+ const query = useQuery({
+ queryKey: ["e621", "post", postId, credentials?.username ?? ""],
+ queryFn: ({ signal }) =>
+ fetchPost(effectiveCredentials(credentials), postId, signal),
+ enabled: Number.isFinite(postId) && postId > 0,
+ });
+
+ const post = query.data;
+
+ const relatedIds = useMemo(() => {
+ if (!post) return [];
+ const ids: number[] = [];
+ if (post.relationships.parent_id) ids.push(post.relationships.parent_id);
+ for (const childId of post.relationships.children) {
+ if (ids.length >= 12) break;
+ ids.push(childId);
+ }
+ return ids;
+ }, [post]);
+
+ const relatedQuery = useQuery({
+ queryKey: [
+ "e621",
+ "posts-by-ids",
+ relatedIds.join(","),
+ credentials?.username ?? "",
+ ],
+ queryFn: ({ signal }) =>
+ fetchPostsByIds(effectiveCredentials(credentials), relatedIds, signal),
+ enabled: relatedIds.length > 0,
+ });
+
+ const lookupQuery = useQuery({
+ queryKey: ["library-lookup", post?.file.md5 ?? ""],
+ queryFn: () =>
+ api<{ found: string[] }>("/api/files/lookup/", {
+ method: "POST",
+ json: { md5s: [post?.file.md5] },
+ }),
+ enabled: Boolean(post?.file.md5),
+ staleTime: 60_000,
+ });
+ const inLibrary = (lookupQuery.data?.found.length ?? 0) > 0;
+
+ const favoriteMutation = useMutation({
+ mutationFn: (favorite: boolean) =>
+ setFavorite(effectiveCredentials(credentials), postId, favorite),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({
+ queryKey: ["e621", "post", postId],
+ });
+ },
+ });
+
+ if (query.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (query.isError || !post) {
+ return (
+
+ Back to the online browser
+
+ )
+ }
+ />
+ );
+ }
+
+ const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
+ const imageSrc =
+ post.sample.has && post.sample.url ? post.sample.url : post.file.url;
+
+ return (
+
+
+
+
Online
+
+
/
+
+ Post #{post.id}
+
+
+
+
+
+
+
+
+
+
+
+ View on e621
+
+
+
+ {favoriteMutation.isError ? (
+
+ {e621ErrorMessage(favoriteMutation.error)}
+
+ ) : null}
+
+
+ {isVideo ? (
+
+ ) : (
+

+ )}
+
+
+ {post.description.trim() ? (
+
+
+
+ Description
+
+
+ {post.description}
+
+
+
+ ) : null}
+
+ {post.sources.length > 0 ? (
+
+
+ Sources
+
+
+ {post.sources.slice(0, 6).map((source) => (
+ -
+
+ {source}
+
+
+ ))}
+
+
+ ) : null}
+
+ {post.pools.length > 0 ? (
+
+ ) : null}
+
+ {relatedIds.length > 0 ? (
+
+
+ Related posts
+
+ {relatedQuery.isPending ? (
+
+ ) : (
+
+ {(relatedQuery.data ?? []).map((related) => (
+
+
+ {related.preview.url ? (
+

+ ) : null}
+
+
+ #{related.id}
+
+
+ ))}
+
+ )}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/features/online/OnlinePage.tsx b/frontend/src/features/online/OnlinePage.tsx
new file mode 100644
index 0000000..cfc3fd1
--- /dev/null
+++ b/frontend/src/features/online/OnlinePage.tsx
@@ -0,0 +1,297 @@
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useMemo, useState } from "react";
+import { Link, useSearchParams } from "react-router-dom";
+
+import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
+import { TagChip } from "@/components/TagChip";
+import { Button, EmptyState, Spinner } from "@/components/ui";
+import { PostCard } from "@/features/online/PostCard";
+import { TagAutocomplete } from "@/features/online/TagAutocomplete";
+import { api } from "@/lib/api";
+import { cn } from "@/lib/cn";
+import {
+ e621ErrorMessage,
+ effectiveCredentials,
+ fetchPosts,
+ postIsBlacklisted,
+ type E621Post,
+} from "@/lib/e621";
+import { useE621 } from "@/store/e621";
+
+const PAGE_SIZE = 48;
+const EMPTY_POSTS: E621Post[] = [];
+
+export default function OnlinePage() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const tags = searchParams.get("tags") ?? "";
+ const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
+
+ const credentials = useE621((state) => state.credentials);
+ const blacklist = useE621((state) => state.blacklist);
+ const [draft, setDraft] = useState(null);
+ const tagInput = draft ?? tags;
+
+ const postsQuery = useQuery({
+ queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
+ queryFn: ({ signal }) =>
+ fetchPosts(effectiveCredentials(credentials), {
+ tags,
+ page,
+ limit: PAGE_SIZE,
+ signal,
+ }),
+ placeholderData: keepPreviousData,
+ staleTime: 30_000,
+ });
+
+ const posts = postsQuery.data ?? EMPTY_POSTS;
+
+ const md5s = useMemo(() => posts.map((post) => post.file.md5), [posts]);
+ const lookupQuery = useQuery({
+ queryKey: ["library-lookup", md5s.join(",")],
+ queryFn: () =>
+ api<{ found: string[] }>("/api/files/lookup/", {
+ method: "POST",
+ json: { md5s },
+ }),
+ enabled: md5s.length > 0,
+ staleTime: 60_000,
+ });
+ const inLibrary = useMemo(
+ () => new Set(lookupQuery.data?.found ?? []),
+ [lookupQuery.data],
+ );
+
+ const blacklistSet = useMemo(() => new Set(blacklist), [blacklist]);
+ const { visiblePosts, blacklistCounts, removedCount } = useMemo(() => {
+ const counts = new Map();
+ const visible: E621Post[] = [];
+ let removed = 0;
+ for (const post of posts) {
+ const matches = postIsBlacklisted(post, blacklistSet);
+ if (matches.length > 0) {
+ removed += 1;
+ for (const match of matches) {
+ counts.set(match, (counts.get(match) ?? 0) + 1);
+ }
+ } else {
+ visible.push(post);
+ }
+ }
+ return {
+ visiblePosts: visible,
+ blacklistCounts: [...counts.entries()].sort((a, b) => b[1] - a[1]),
+ removedCount: removed,
+ };
+ }, [posts, blacklistSet]);
+
+ const pageTags = useMemo(() => {
+ const counts = new Map();
+ for (const post of visiblePosts) {
+ for (const list of Object.values(post.tags)) {
+ for (const tag of list) {
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
+ }
+ }
+ }
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
+ }, [visiblePosts]);
+
+ const hasNext = posts.length >= PAGE_SIZE;
+
+ function buildParams(tagValue: string, pageValue: number): URLSearchParams {
+ const params = new URLSearchParams();
+ if (tagValue.trim()) params.set("tags", tagValue.trim());
+ if (pageValue > 1) params.set("page", String(pageValue));
+ return params;
+ }
+
+ function applySearch() {
+ setDraft(null);
+ setSearchParams(buildParams(tagInput, 1));
+ }
+
+ function clearSearch() {
+ setDraft(null);
+ setSearchParams(new URLSearchParams());
+ }
+
+ function addTag(tag: string) {
+ const next = tags ? `${tags} ${tag}` : tag;
+ setDraft(null);
+ setSearchParams(buildParams(next, 1));
+ }
+
+ function goToPage(nextPage: number) {
+ setSearchParams(buildParams(tags, nextPage));
+ }
+
+ return (
+
+
+
+
+
+
+ Tags on this page
+
+
+ {pageTags.map(([tag, count]) => (
+ addTag(tag)}
+ />
+ ))}
+ {pageTags.length === 0 && postsQuery.isPending ? (
+
+ ) : null}
+
+
+
+
+
+ Your blacklist
+
+ {blacklist.length > 0 ? (
+
+ {blacklist.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : (
+
No blacklisted tags.
+ )}
+
+
+
+
+ Blacklisted tags removed
+
+ {blacklistCounts.length > 0 ? (
+
+ {blacklistCounts.map(([tag, count]) => (
+
+ {tag} ({count})
+
+ ))}
+
+ ) : (
+
+ Nothing filtered on this page.
+
+ )}
+
+
+
+
+
+
+ {postsQuery.data
+ ? `${visiblePosts.length} post${visiblePosts.length === 1 ? "" : "s"} shown${
+ removedCount > 0
+ ? ` · ${removedCount} hidden by blacklist`
+ : ""
+ }`
+ : "…"}
+
+
Page {page}
+
+
+ {credentials && !credentials.configured ? (
+
+ Browsing anonymously.{" "}
+
+ Add e621 credentials
+ {" "}
+ for favorites and your personal blacklist.
+
+ ) : null}
+
+ {postsQuery.isPending ? (
+
+
+
+ ) : postsQuery.isError ? (
+
+ ) : visiblePosts.length === 0 ? (
+ 0
+ ? "Everything on this page was blacklisted."
+ : "Try different tags."
+ }
+ />
+ ) : (
+
+ {visiblePosts.map((post) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/online/PostCard.tsx b/frontend/src/features/online/PostCard.tsx
new file mode 100644
index 0000000..7d77897
--- /dev/null
+++ b/frontend/src/features/online/PostCard.tsx
@@ -0,0 +1,76 @@
+import { CheckCircle2 } from "lucide-react";
+import { Link } from "react-router-dom";
+
+import { cn } from "@/lib/cn";
+import type { E621Post } from "@/lib/e621";
+import { formatBytes } from "@/lib/format";
+
+const ratingBorder: Record = {
+ s: "border-ctp-green/25 hover:border-ctp-green/60",
+ q: "border-ctp-peach/25 hover:border-ctp-peach/60",
+ e: "border-ctp-red/25 hover:border-ctp-red/60",
+};
+
+const ratingPill: Record = {
+ s: "bg-ctp-green text-ctp-crust",
+ q: "bg-ctp-peach text-ctp-crust",
+ e: "bg-ctp-red text-ctp-crust",
+};
+
+export function PostCard({
+ post,
+ inLibrary,
+}: {
+ post: E621Post;
+ inLibrary: boolean;
+}) {
+ return (
+
+
+ {post.preview.url ? (
+

+ ) : (
+
+ No preview
+
+ )}
+
+ {post.rating}
+
+
+ {post.file.ext}
+
+ {inLibrary ? (
+
+ IN
+
+ ) : null}
+
+
+ #{post.id}
+
+ ▲ {post.score.total} · {formatBytes(post.file.size)}
+
+
+
+ );
+}
diff --git a/frontend/src/features/online/TagAutocomplete.tsx b/frontend/src/features/online/TagAutocomplete.tsx
new file mode 100644
index 0000000..012d1b5
--- /dev/null
+++ b/frontend/src/features/online/TagAutocomplete.tsx
@@ -0,0 +1,125 @@
+import { useQuery } from "@tanstack/react-query";
+import { Search } from "lucide-react";
+import { useState } from "react";
+
+import { inputClass } from "@/components/ui";
+import { cn } from "@/lib/cn";
+import {
+ effectiveCredentials,
+ fetchTagSuggestions,
+ tagCategoryName,
+ type E621Tag,
+} from "@/lib/e621";
+import { useDebouncedValue } from "@/lib/hooks";
+import { useE621 } from "@/store/e621";
+
+const categoryDot: Record = {
+ general: "bg-ctp-blue",
+ artist: "bg-ctp-yellow",
+ copyright: "bg-ctp-mauve",
+ character: "bg-ctp-green",
+ species: "bg-ctp-peach",
+ meta: "bg-ctp-subtext0",
+ lore: "bg-ctp-teal",
+ invalid: "bg-ctp-red",
+ contributor: "bg-ctp-lavender",
+ unknown: "bg-ctp-surface2",
+};
+
+export function TagAutocomplete({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const credentials = useE621((state) => state.credentials);
+ const [open, setOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const lastWord = value.split(" ").pop() ?? "";
+ const debounced = useDebouncedValue(lastWord, 450);
+
+ const suggestionsQuery = useQuery({
+ queryKey: ["e621", "tags", debounced, credentials?.username ?? ""],
+ queryFn: ({ signal }) =>
+ fetchTagSuggestions(effectiveCredentials(credentials), debounced, signal),
+ enabled: debounced.trim().length >= 2,
+ staleTime: 60_000,
+ });
+ const suggestions = suggestionsQuery.data ?? [];
+ const visible = open && suggestions.length > 0;
+
+ function pick(tag: E621Tag) {
+ const prefix = value.slice(0, Math.max(0, value.length - lastWord.length));
+ onChange(`${prefix}${tag.name} `);
+ setOpen(false);
+ setActiveIndex(0);
+ }
+
+ return (
+
+
+
{
+ onChange(event.target.value);
+ setOpen(true);
+ setActiveIndex(0);
+ }}
+ onKeyDown={(event) => {
+ if (!visible) return;
+ const suggestion = suggestions[activeIndex];
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.min(index + 1, suggestions.length - 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.max(index - 1, 0));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ if (suggestion) pick(suggestion);
+ } else if (event.key === "Escape") {
+ setOpen(false);
+ }
+ }}
+ onBlur={() => window.setTimeout(() => setOpen(false), 120)}
+ />
+ {visible ? (
+
+ {suggestions.map((tag, index) => (
+ -
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/lib/e621.ts b/frontend/src/lib/e621.ts
index 59df447..4420a60 100644
--- a/frontend/src/lib/e621.ts
+++ b/frontend/src/lib/e621.ts
@@ -12,6 +12,50 @@ export interface E621User {
blacklisted_tags?: string;
}
+export interface E621Tag {
+ id: number;
+ name: string;
+ post_count: number;
+ category: number;
+}
+
+export interface E621Post {
+ id: number;
+ created_at: string;
+ rating: "s" | "q" | "e";
+ tags: Record;
+ file: {
+ width: number;
+ height: number;
+ size: number;
+ ext: string;
+ md5: string;
+ url: string | null;
+ };
+ preview: { url: string | null; width: number; height: number };
+ sample: {
+ has: boolean;
+ url: string | null;
+ width: number;
+ height: number;
+ };
+ score: { up: number; down: number; total: number };
+ fav_count: number;
+ comment_count: number;
+ is_favorited: boolean;
+ vote: number | null;
+ sources: string[];
+ description: string;
+ pools: number[];
+ relationships: {
+ parent_id: number | null;
+ has_children: boolean;
+ has_active_children: boolean;
+ children: number[];
+ };
+ uploader_name?: string;
+}
+
export class E621Error extends Error {
status: number;
data: unknown;
@@ -24,6 +68,19 @@ export class E621Error extends Error {
}
}
+export const ANONYMOUS_CREDENTIALS: E621Credentials = {
+ username: "",
+ api_key: "",
+ base_url: "https://e621.net",
+ configured: false,
+};
+
+export function effectiveCredentials(
+ credentials: E621Credentials | null,
+): E621Credentials {
+ return credentials ?? ANONYMOUS_CREDENTIALS;
+}
+
const CLIENT_VERSION = "J621/0.1";
// e621 allows 2 requests/second hard, 1/second sustained. Serialize every
@@ -142,3 +199,151 @@ export async function verifyE621Credentials(
);
return data.user ?? (data as E621User);
}
+
+export async function fetchPosts(
+ credentials: E621Credentials,
+ options: {
+ tags?: string;
+ page?: number;
+ limit?: number;
+ signal?: AbortSignal;
+ } = {},
+): Promise {
+ const data = await e621Request<{ posts: E621Post[] }>(
+ credentials,
+ "/posts.json",
+ {
+ params: {
+ tags: options.tags || undefined,
+ page: options.page,
+ limit: options.limit ?? 48,
+ },
+ signal: options.signal,
+ },
+ );
+ return data.posts;
+}
+
+export async function fetchPost(
+ credentials: E621Credentials,
+ id: number,
+ signal?: AbortSignal,
+): Promise {
+ const data = await e621Request<{ post: E621Post }>(
+ credentials,
+ `/posts/${id}.json`,
+ { signal },
+ );
+ return data.post;
+}
+
+export async function fetchPostsByIds(
+ credentials: E621Credentials,
+ ids: number[],
+ signal?: AbortSignal,
+): Promise {
+ if (ids.length === 0) return [];
+ const data = await e621Request<{ posts: E621Post[] }>(
+ credentials,
+ "/posts.json",
+ {
+ params: { tags: `id:${ids.join(",")}`, limit: Math.min(ids.length, 100) },
+ signal,
+ },
+ );
+ return data.posts;
+}
+
+export async function fetchTagSuggestions(
+ credentials: E621Credentials,
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ const trimmed = query.trim();
+ if (!trimmed) return [];
+ return e621Request(credentials, "/tags.json", {
+ params: {
+ "search[name_matches]": `*${trimmed}*`,
+ "search[order]": "count",
+ limit: 8,
+ },
+ signal,
+ });
+}
+
+export async function setFavorite(
+ credentials: E621Credentials,
+ postId: number,
+ favorite: boolean,
+): Promise {
+ if (favorite) {
+ await e621Request(credentials, "/favorites.json", {
+ method: "POST",
+ body: new URLSearchParams({ post_id: String(postId) }),
+ });
+ return;
+ }
+ await e621Request(credentials, `/favorites/${postId}.json`, {
+ method: "DELETE",
+ });
+}
+
+export function postIsBlacklisted(
+ post: E621Post,
+ blacklist: Set,
+): string[] {
+ if (blacklist.size === 0) return [];
+ const matches: string[] = [];
+ for (const tags of Object.values(post.tags)) {
+ for (const tag of tags) {
+ if (blacklist.has(tag)) matches.push(tag);
+ }
+ }
+ return matches;
+}
+
+export const TAG_CATEGORY_ORDER = [
+ "general",
+ "species",
+ "character",
+ "copyright",
+ "artist",
+ "meta",
+ "lore",
+ "invalid",
+ "contributor",
+] as const;
+
+export const TAG_CATEGORY_LABELS: Record = {
+ general: "General",
+ species: "Species",
+ character: "Character",
+ copyright: "Copyright",
+ artist: "Artist",
+ meta: "Meta",
+ lore: "Lore",
+ invalid: "Invalid",
+ contributor: "Contributor",
+};
+
+const CATEGORY_BY_NUMBER: Record = {
+ 0: "general",
+ 1: "artist",
+ 3: "copyright",
+ 4: "character",
+ 5: "species",
+ 6: "invalid",
+ 7: "meta",
+ 8: "lore",
+ 9: "contributor",
+};
+
+export function tagCategoryName(category: number): string {
+ return CATEGORY_BY_NUMBER[category] ?? "unknown";
+}
+
+export const RATING_LABELS: Record = {
+ s: "Safe",
+ q: "Questionable",
+ e: "Explicit",
+};
diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts
new file mode 100644
index 0000000..cb983dc
--- /dev/null
+++ b/frontend/src/lib/hooks.ts
@@ -0,0 +1,12 @@
+import { useEffect, useState } from "react";
+
+export function useDebouncedValue(value: T, delayMs: number): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const handle = window.setTimeout(() => setDebounced(value), delayMs);
+ return () => window.clearTimeout(handle);
+ }, [value, delayMs]);
+
+ return debounced;
+}
diff --git a/frontend/src/store/e621.ts b/frontend/src/store/e621.ts
index 8dcae98..e6cbdc7 100644
--- a/frontend/src/store/e621.ts
+++ b/frontend/src/store/e621.ts
@@ -1,12 +1,19 @@
import { create } from "zustand";
import { api } from "@/lib/api";
-import type { E621Credentials } from "@/lib/e621";
+import {
+ effectiveCredentials,
+ verifyE621Credentials,
+ type E621Credentials,
+} from "@/lib/e621";
interface E621State {
credentials: E621Credentials | null;
+ blacklist: string[];
loading: boolean;
+ ready: boolean;
load: () => Promise;
+ loadBlacklist: () => Promise;
save: (values: {
username: string;
api_key: string;
@@ -15,17 +22,35 @@ interface E621State {
clear: () => void;
}
-export const useE621 = create((set) => ({
+export const useE621 = create((set, get) => ({
credentials: null,
+ blacklist: [],
loading: false,
+ ready: false,
load: async () => {
set({ loading: true });
try {
const credentials = await api("/api/auth/e621/");
- set({ credentials, loading: false });
+ set({ credentials, ready: true, loading: false });
+ void get().loadBlacklist();
} catch {
- set({ credentials: null, loading: false });
+ set({ credentials: null, ready: true, loading: false });
+ }
+ },
+
+ loadBlacklist: async () => {
+ try {
+ const user = await verifyE621Credentials(
+ effectiveCredentials(get().credentials),
+ );
+ const tags = (user.blacklisted_tags ?? "")
+ .split("\n")
+ .map((tag) => tag.trim())
+ .filter(Boolean);
+ set({ blacklist: tags });
+ } catch {
+ // Keep the last known blacklist when e621 is unreachable.
}
},
@@ -35,8 +60,9 @@ export const useE621 = create((set) => ({
json: values,
});
set({ credentials });
+ void get().loadBlacklist();
return credentials;
},
- clear: () => set({ credentials: null }),
+ clear: () => set({ credentials: null, blacklist: [], ready: false }),
}));