Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download

Backend:
- User.role (user/uploader/staff) with can_upload; uploads and downloads
  gated to uploader+; owners and staff can edit their items
- MediaItem.uploaded_by plus J-<id> identity (serializer, admin,
  scan_files --user, first superuser as default owner)
- API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup
  return j_ids
- Guest safety: mirror e621's anonymous default blacklist into Redis
  (parses comments, negations and wildcards), flag hidden_from_guests
  and filter lists, details and lookups for anonymous users
- POST /api/online/downloads/ writes an e621 file into the watched
  folder and indexes it for the uploader
- MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL
  driver shim, Redis cache replacing the file cache; SQLite data
  dumped and loaded into MariaDB

Frontend:
- Single /detail/:itemId route with an adaptive shell: J-<id> renders
  the library item, bare numbers render the e621 post
- Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs
- Cards expose J-IDs; library custom-data editor is read-only for
  non-owners
- Role gating: Upload hidden/blocked for regular users, account shows
  the role, guest hint on the library
This commit is contained in:
2026-09-17 10:16:45 -05:00
parent d6c3f90c1f
commit e5cc63b0cc
36 changed files with 954 additions and 194 deletions
@@ -0,0 +1,449 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
CheckCircle2,
Download,
ExternalLink,
Heart,
HeartOff,
} from "lucide-react";
import { useMemo } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
import { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import {
e621ErrorMessage,
effectiveCredentials,
fetchPost,
fetchPostsByIds,
RATING_LABELS,
setFavorite,
TAG_CATEGORY_LABELS,
TAG_CATEGORY_ORDER,
} from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
import { useAuth } from "@/store/auth";
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",
};
interface LibraryLookup {
found: string[];
j_ids: Record<string, string>;
}
export function OnlineDetail({ postId }: { postId: number }) {
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: string } | null)?.from ?? "/online";
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const user = useAuth((state) => state.user);
const query = useQuery({
queryKey: ["e621", "post", postId, credentials?.username ?? ""],
queryFn: ({ signal }) =>
fetchPost(effectiveCredentials(credentials), postId, signal),
enabled: Number.isFinite(postId) && postId > 0,
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
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,
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
const lookupQuery = useQuery({
queryKey: ["library-lookup", post?.file.md5 ?? ""],
queryFn: () =>
api<LibraryLookup>("/api/files/lookup/", {
method: "POST",
json: { md5s: [post?.file.md5] },
}),
enabled: Boolean(post?.file.md5),
staleTime: 60_000,
});
const libraryJId = post
? lookupQuery.data?.j_ids?.[post.file.md5]
: undefined;
const favoriteMutation = useMutation({
mutationFn: (favorite: boolean) =>
setFavorite(effectiveCredentials(credentials), postId, favorite),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["e621", "post", postId],
});
},
});
const downloadMutation = useMutation({
mutationFn: (target: NonNullable<typeof post>) =>
api<MediaItem>("/api/online/downloads/", {
method: "POST",
json: {
post_id: target.id,
url: target.file.url,
filename: `${target.file.md5}.${target.file.ext}`,
},
}),
onSuccess: (item) => {
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
navigate(`/detail/${item.j_id}`);
},
});
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;
const canUpload = Boolean(user?.can_upload);
return (
<DetailShell
breadcrumb={
<div className="flex items-center gap-3 text-sm">
<Link
to={from}
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>
}
actions={
<>
<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>
{libraryJId ? (
<Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
</Link>
) : (
<Button
variant="secondary"
disabled={
!canUpload || downloadMutation.isPending || !post.file.url
}
onClick={() => downloadMutation.mutate(post)}
title={
canUpload
? undefined
: "Your account is not allowed to add to the library"
}
>
<Download className="h-4 w-4" />
{downloadMutation.isPending
? "Downloading…"
: "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}
{downloadMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(downloadMutation.error)}
</p>
) : null}
</>
}
media={
<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>
}
aside={
<>
<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>
</section>
</>
}
footer={
<>
{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>
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
{renderDText(post.description, credentials?.base_url)}
</div>
</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={`/detail/${related.id}`}
state={{ from }}
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}
</>
}
/>
);
}