Online browser + post detail: SPA talks to e621 directly

Backend:
- POST /api/files/lookup/ reports which MD5s are already in the library

Frontend:
- e621 client extended with post/tag/favorite types and helpers: post
  search, post detail, batch posts by id, tag autocomplete, toggle
  favorite
- /online: tag search with autocomplete, post grid with rating colors
  and in-library badges, numbered pagination, page tag cloud, blacklist
  panel and filtered counts from /users/me.json, anonymous hint
- /online/view/🆔 media viewer (sample or original, video support),
  taxonomy-colored tags by category, specs sheet, favorite/unfavorite,
  description, sources, pools and parent/children thumbnails
- Shared CollapsibleSidebar extracted from the library page; Online
  added to the nav, command palette and sidebar toggle
This commit is contained in:
2026-09-17 09:00:17 -05:00
parent b6409523e6
commit 04448bf615
14 changed files with 1273 additions and 41 deletions
+205
View File
@@ -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<string, string[]>;
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<E621Post[]> {
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<E621Post> {
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<E621Post[]> {
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<E621Tag[]> {
const trimmed = query.trim();
if (!trimmed) return [];
return e621Request<E621Tag[]>(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<void> {
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>,
): 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<string, string> = {
general: "General",
species: "Species",
character: "Character",
copyright: "Copyright",
artist: "Artist",
meta: "Meta",
lore: "Lore",
invalid: "Invalid",
contributor: "Contributor",
};
const CATEGORY_BY_NUMBER: Record<number, string> = {
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<string, string> = {
s: "Safe",
q: "Questionable",
e: "Explicit",
};
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(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;
}