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