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:
@@ -8,6 +8,8 @@ import LoginPage from "@/features/auth/LoginPage";
|
||||
import RegisterPage from "@/features/auth/RegisterPage";
|
||||
import FileDetailPage from "@/features/library/FileDetailPage";
|
||||
import LibraryPage from "@/features/library/LibraryPage";
|
||||
import OnlineDetailPage from "@/features/online/OnlineDetailPage";
|
||||
import OnlinePage from "@/features/online/OnlinePage";
|
||||
import UploadPage from "@/features/upload/UploadPage";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
@@ -37,6 +39,8 @@ export default function App() {
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<LibraryPage />} />
|
||||
<Route path="/view/:md5" element={<FileDetailPage />} />
|
||||
<Route path="/online" element={<OnlinePage />} />
|
||||
<Route path="/online/view/:id" element={<OnlineDetailPage />} />
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
|
||||
@@ -10,10 +10,12 @@ import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { SystemStatus } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useE621 } from "@/store/e621";
|
||||
import { useUi } from "@/store/ui";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Library" },
|
||||
{ to: "/online", label: "Online" },
|
||||
{ to: "/upload", label: "Upload" },
|
||||
];
|
||||
|
||||
@@ -21,9 +23,14 @@ export function AppShell() {
|
||||
const user = useAuth((state) => state.user);
|
||||
const logout = useAuth((state) => state.logout);
|
||||
const toggleSidebar = useUi((state) => state.toggleSidebar);
|
||||
const loadE621 = useE621((state) => state.load);
|
||||
const location = useLocation();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void loadE621();
|
||||
}, [loadE621]);
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["status"],
|
||||
queryFn: () => api<SystemStatus>("/api/status/"),
|
||||
@@ -53,7 +60,8 @@ export function AppShell() {
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
const showSidebarToggle = location.pathname === "/";
|
||||
const showSidebarToggle =
|
||||
location.pathname === "/" || location.pathname === "/online";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-ctp-crust/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col gap-5 overflow-y-auto border-r border-ctp-surface0 bg-ctp-base p-4",
|
||||
"lg:sticky lg:inset-y-auto lg:top-20 lg:z-auto lg:w-64 lg:shrink-0 lg:overflow-visible lg:rounded-lg lg:border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-ctp-subtext1">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title="Close filters"
|
||||
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text lg:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const TAG_STYLES: Record<string, string> = {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 font-mono text-[11px] transition",
|
||||
style,
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
{count !== undefined ? ` (${count})` : ""}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
{sidebarOpen ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-ctp-crust/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col gap-5 overflow-y-auto border-r border-ctp-surface0 bg-ctp-base p-4",
|
||||
"lg:sticky lg:inset-y-auto lg:top-20 lg:z-auto lg:w-64 lg:shrink-0 lg:overflow-visible lg:rounded-lg lg:border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-ctp-subtext1">
|
||||
Filters
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title="Close filters"
|
||||
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text lg:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{filterPanel}
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
<CollapsibleSidebar>{filterPanel}</CollapsibleSidebar>
|
||||
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
s: "text-ctp-green",
|
||||
q: "text-ctp-peach",
|
||||
e: "text-ctp-red",
|
||||
};
|
||||
|
||||
const relatedBorder: Record<string, string> = {
|
||||
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 (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || !post) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Could not load this post"
|
||||
description={
|
||||
query.isError ? (
|
||||
e621ErrorMessage(query.error)
|
||||
) : (
|
||||
<Link to="/online" className="text-ctp-blue hover:underline">
|
||||
Back to the online browser
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link
|
||||
to="/online"
|
||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Online
|
||||
</Link>
|
||||
<span className="text-ctp-surface1">/</span>
|
||||
<span className="font-mono text-xs text-ctp-overlay0">
|
||||
Post #{post.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="order-2 flex flex-col gap-4 lg:order-1">
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{TAG_CATEGORY_ORDER.map((category) => {
|
||||
const tags = post.tags[category] ?? [];
|
||||
if (tags.length === 0) return null;
|
||||
return (
|
||||
<div key={category}>
|
||||
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-ctp-overlay0">
|
||||
{TAG_CATEGORY_LABELS[category] ?? category}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<TagChip
|
||||
key={tag}
|
||||
tag={tag}
|
||||
category={category}
|
||||
onClick={() =>
|
||||
navigate(`/online?tags=${encodeURIComponent(tag)}`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Specs</h2>
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
|
||||
<dt className="text-ctp-overlay0">Rating</dt>
|
||||
<dd className={cn("font-medium", ratingText[post.rating])}>
|
||||
{RATING_LABELS[post.rating] ?? post.rating}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Score</dt>
|
||||
<dd className="font-mono text-ctp-subtext0">
|
||||
{post.score.up} ▲ / {post.score.down} ▼
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Favorites</dt>
|
||||
<dd className="font-mono text-ctp-subtext0">{post.fav_count}</dd>
|
||||
<dt className="text-ctp-overlay0">Comments</dt>
|
||||
<dd className="font-mono text-ctp-subtext0">
|
||||
{post.comment_count}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">File</dt>
|
||||
<dd className="font-mono uppercase text-ctp-subtext0">
|
||||
{post.file.ext} · {formatBytes(post.file.size)}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Dimensions</dt>
|
||||
<dd className="font-mono text-ctp-subtext0">
|
||||
{post.file.width}×{post.file.height}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">MD5</dt>
|
||||
<dd
|
||||
className="truncate font-mono text-ctp-subtext0"
|
||||
title={post.file.md5}
|
||||
>
|
||||
{post.file.md5}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Posted</dt>
|
||||
<dd className="text-ctp-subtext0">{formatDate(post.created_at)}</dd>
|
||||
{post.uploader_name ? (
|
||||
<>
|
||||
<dt className="text-ctp-overlay0">Uploader</dt>
|
||||
<dd className="text-ctp-subtext0">{post.uploader_name}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{inLibrary ? (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-ctp-green">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> In your library
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={post.is_favorited ? "secondary" : "primary"}
|
||||
disabled={!credentials?.configured || favoriteMutation.isPending}
|
||||
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
||||
title={
|
||||
credentials?.configured
|
||||
? undefined
|
||||
: "Add e621 credentials in Account to favorite posts"
|
||||
}
|
||||
>
|
||||
{post.is_favorited ? (
|
||||
<HeartOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Heart className="h-4 w-4" />
|
||||
)}
|
||||
{favoriteMutation.isPending
|
||||
? "Saving…"
|
||||
: post.is_favorited
|
||||
? "Unfavorite"
|
||||
: "Favorite"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled
|
||||
title="Coming with the Download to Library update"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download to Library
|
||||
</Button>
|
||||
<a
|
||||
href={`https://e621.net/posts/${post.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkButtonClass}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> View on e621
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{favoriteMutation.isError ? (
|
||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||
{e621ErrorMessage(favoriteMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||
{isVideo ? (
|
||||
<video
|
||||
key={post.id}
|
||||
src={post.file.url ?? undefined}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={post.id}
|
||||
src={imageSrc ?? undefined}
|
||||
alt={`Post #${post.id}`}
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.description.trim() ? (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-semibold text-ctp-subtext1">
|
||||
Description
|
||||
</summary>
|
||||
<p className="mt-2 whitespace-pre-line text-sm text-ctp-subtext0">
|
||||
{post.description}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{post.sources.length > 0 ? (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Sources
|
||||
</h2>
|
||||
<ul className="mt-2 flex flex-col gap-1">
|
||||
{post.sources.slice(0, 6).map((source) => (
|
||||
<li key={source} className="truncate">
|
||||
<a
|
||||
href={source}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs text-ctp-blue hover:underline"
|
||||
title={source}
|
||||
>
|
||||
{source}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{post.pools.length > 0 ? (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Pools</h2>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{post.pools.map((poolId) => (
|
||||
<a
|
||||
key={poolId}
|
||||
href={`https://e621.net/pools/${poolId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0 transition hover:bg-ctp-surface0"
|
||||
>
|
||||
Pool #{poolId}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{relatedIds.length > 0 ? (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Related posts
|
||||
</h2>
|
||||
{relatedQuery.isPending ? (
|
||||
<Spinner className="mt-3 h-4 w-4" />
|
||||
) : (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{(relatedQuery.data ?? []).map((related) => (
|
||||
<Link
|
||||
key={related.id}
|
||||
to={`/online/view/${related.id}`}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
||||
relatedBorder[related.rating] ?? "border-ctp-surface0",
|
||||
)}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden">
|
||||
{related.preview.url ? (
|
||||
<img
|
||||
src={related.preview.url}
|
||||
alt={`Post #${related.id}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
|
||||
#{related.id}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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<string, number>();
|
||||
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<string, number>();
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<CollapsibleSidebar>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
applySearch();
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Tags
|
||||
</span>
|
||||
<TagAutocomplete value={tagInput} onChange={setDraft} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
Search
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={clearSearch}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
||||
e621 syntax: space separated, <span className="font-mono">-tag</span>{" "}
|
||||
excludes, <span className="font-mono">~tag</span> ORs.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Tags on this page
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pageTags.map(([tag, count]) => (
|
||||
<TagChip
|
||||
key={tag}
|
||||
tag={tag}
|
||||
count={count}
|
||||
onClick={() => addTag(tag)}
|
||||
/>
|
||||
))}
|
||||
{pageTags.length === 0 && postsQuery.isPending ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Your blacklist
|
||||
</span>
|
||||
{blacklist.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blacklist.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ctp-overlay0">No blacklisted tags.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Blacklisted tags removed
|
||||
</span>
|
||||
{blacklistCounts.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blacklistCounts.map(([tag, count]) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
||||
>
|
||||
{tag} ({count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ctp-overlay0">
|
||||
Nothing filtered on this page.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSidebar>
|
||||
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-ctp-overlay0">
|
||||
{postsQuery.data
|
||||
? `${visiblePosts.length} post${visiblePosts.length === 1 ? "" : "s"} shown${
|
||||
removedCount > 0
|
||||
? ` · ${removedCount} hidden by blacklist`
|
||||
: ""
|
||||
}`
|
||||
: "…"}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-ctp-overlay0">Page {page}</p>
|
||||
</div>
|
||||
|
||||
{credentials && !credentials.configured ? (
|
||||
<p className="mb-4 rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-overlay0">
|
||||
Browsing anonymously.{" "}
|
||||
<Link to="/account" className="text-ctp-blue hover:underline">
|
||||
Add e621 credentials
|
||||
</Link>{" "}
|
||||
for favorites and your personal blacklist.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{postsQuery.isPending ? (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : postsQuery.isError ? (
|
||||
<EmptyState
|
||||
title="Could not load posts from e621"
|
||||
description={e621ErrorMessage(postsQuery.error)}
|
||||
/>
|
||||
) : visiblePosts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No posts found"
|
||||
description={
|
||||
removedCount > 0
|
||||
? "Everything on this page was blacklisted."
|
||||
: "Try different tags."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3 transition-opacity",
|
||||
postsQuery.isPlaceholderData && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{visiblePosts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
inLibrary={inLibrary.has(post.file.md5)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1 || postsQuery.isFetching}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!hasNext || postsQuery.isFetching}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<Link
|
||||
to={`/online/view/${post.id}`}
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-lg border bg-ctp-base transition",
|
||||
ratingBorder[post.rating] ?? "border-ctp-surface0",
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
||||
{post.preview.url ? (
|
||||
<img
|
||||
src={post.preview.url}
|
||||
alt={`Post #${post.id}`}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-ctp-overlay0">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
|
||||
ratingPill[post.rating] ?? "bg-ctp-surface1 text-ctp-text",
|
||||
)}
|
||||
>
|
||||
{post.rating}
|
||||
</span>
|
||||
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] uppercase text-ctp-overlay1">
|
||||
{post.file.ext}
|
||||
</span>
|
||||
{inLibrary ? (
|
||||
<span
|
||||
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-ctp-green/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust"
|
||||
title="Already in your library"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" /> IN
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
|
||||
<span className="font-mono text-xs text-ctp-subtext1">#{post.id}</span>
|
||||
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||
▲ {post.score.total} · {formatBytes(post.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
|
||||
<input
|
||||
className={cn(inputClass, "pl-8")}
|
||||
placeholder="Tags: dragon solo -female"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
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 ? (
|
||||
<ul className="absolute left-0 right-0 top-full z-30 mt-1 overflow-hidden rounded-md border border-ctp-surface1 bg-ctp-surface0 py-1 shadow-[0_4px_12px_rgba(17,17,27,0.6)]">
|
||||
{suggestions.map((tag, index) => (
|
||||
<li key={tag.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
pick(tag);
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono text-xs transition",
|
||||
index === activeIndex
|
||||
? "bg-ctp-surface1 text-ctp-text"
|
||||
: "text-ctp-subtext0",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
categoryDot[tagCategoryName(tag.category)],
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{tag.name}</span>
|
||||
<span className="text-[10px] text-ctp-overlay0">
|
||||
{tag.post_count.toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
loadBlacklist: () => Promise<void>;
|
||||
save: (values: {
|
||||
username: string;
|
||||
api_key: string;
|
||||
@@ -15,17 +22,35 @@ interface E621State {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useE621 = create<E621State>((set) => ({
|
||||
export const useE621 = create<E621State>((set, get) => ({
|
||||
credentials: null,
|
||||
blacklist: [],
|
||||
loading: false,
|
||||
ready: false,
|
||||
|
||||
load: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const credentials = await api<E621Credentials>("/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<E621State>((set) => ({
|
||||
json: values,
|
||||
});
|
||||
set({ credentials });
|
||||
void get().loadBlacklist();
|
||||
return credentials;
|
||||
},
|
||||
|
||||
clear: () => set({ credentials: null }),
|
||||
clear: () => set({ credentials: null, blacklist: [], ready: false }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user