Files
J621/frontend/src/features/online/OnlineDetail.tsx
T
JakeBreath e97c3b9da0 Toast action results and confirm destructive actions in one dialog
Inline banners and per-row status text reported action results all over
the app; they are replaced by a small toast stack (bottom-right, Level 3
floating well styling) that only speaks for actions: successes fade,
errors stay until dismissed, and form-field validation stays inline.

Destructive actions no longer use bespoke inline confirm steps (the
library detail's Confirm delete button) or fire immediately (duplicate
copies/items, delete page selections, temp cleanup, upload discard, job
cancellation): they all go through one promise-based confirm dialog
(confirmAction) with a danger variant, Escape/backdrop to cancel.
2026-09-17 22:19:54 -05:00

606 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
CheckCircle2,
Download,
ExternalLink,
Heart,
HeartOff,
} from "lucide-react";
import { useMemo, useEffect, useState } 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, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import { isTypingTarget } from "@/lib/dom";
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 { DownloadTask, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
import { toast } from "@/store/toasts";
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],
});
},
onError: (error) => toast.error(e621ErrorMessage(error)),
});
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
// Server-side tasks outlive this page; re-attach to the newest one for this
// post so navigating away and back keeps the progress view.
const tasksQuery = useQuery({
queryKey: ["download-tasks", postId],
queryFn: () =>
api<Paginated<DownloadTask>>(
`/api/online/downloads/?post_id=${postId}`,
),
enabled: Boolean(user?.can_upload),
staleTime: 10_000,
});
const remoteActiveTask = tasksQuery.data?.results.find(
(task) => task.status === "pending" || task.status === "downloading",
);
const remoteFinishedTask = tasksQuery.data?.results.find(
(task) => task.status === "complete",
);
const effectiveTaskId = downloadTaskId ?? remoteActiveTask?.task_id ?? null;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
if (event.key.toLowerCase() !== "f") return;
if (!post || !credentials?.configured || favoriteMutation.isPending) return;
event.preventDefault();
favoriteMutation.mutate(!post.is_favorited);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [post, credentials, favoriteMutation]);
const downloadMutation = useMutation({
mutationFn: (target: NonNullable<typeof post>) =>
api<DownloadTask>("/api/online/downloads/", {
method: "POST",
json: {
post_id: target.id,
url: target.file.url,
filename: `${target.file.md5}.${target.file.ext}`,
post: target,
},
}),
onSuccess: (task) => {
setDownloadTaskId(task.task_id);
void queryClient.invalidateQueries({ queryKey: ["status"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
const taskQuery = useQuery({
queryKey: ["download-task", effectiveTaskId],
queryFn: () =>
api<DownloadTask>(`/api/online/downloads/${effectiveTaskId}/`),
enabled: Boolean(effectiveTaskId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "pending" || status === "downloading" ? 700 : false;
},
});
const downloadTask = taskQuery.data;
const downloadStatus = downloadTask?.status;
const downloadActive =
downloadStatus === "pending" || downloadStatus === "downloading";
useEffect(() => {
if (!downloadStatus) return;
if (
downloadStatus === "complete" ||
downloadStatus === "error" ||
downloadStatus === "cancelled"
) {
void queryClient.invalidateQueries({ queryKey: ["status"] });
}
if (downloadStatus === "complete") {
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["download-tasks"] });
}
}, [downloadStatus, queryClient]);
function cancelDownload() {
if (!effectiveTaskId) return;
void api(`/api/online/downloads/${effectiveTaskId}/cancel/`, {
method: "POST",
}).then(() => {
void queryClient.invalidateQueries({ queryKey: ["status"] });
void queryClient.invalidateQueries({ queryKey: ["download-tasks"] });
});
}
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);
const clientDownloadUrl = post.file.url
? `/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
`${post.file.md5}.${post.file.ext}`,
)}`
: null;
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
? "Toggle favorite (F)"
: "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 ||
downloadActive ||
!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 || downloadActive
? "Downloading…"
: "Download to Library"}
</Button>
)}
{clientDownloadUrl ? (
<a
href={clientDownloadUrl}
className={linkButtonClass}
title="Save the original file straight to your computer"
>
<Download className="h-4 w-4" /> Download to client
</a>
) : null}
<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>
{downloadTask &&
(downloadTask.status === "pending" ||
downloadTask.status === "downloading") ? (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
<span className="text-ctp-subtext1">
Downloading to the library… {downloadTask.progress}%
</span>
<span className="font-mono text-ctp-overlay0">
{formatBytes(downloadTask.downloaded)}
{downloadTask.total > 0
? ` / ${formatBytes(downloadTask.total)}`
: ""}
{downloadTask.speed
? ` · ${(downloadTask.speed / 1024 / 1024).toFixed(1)} MB/s`
: ""}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-[width]"
style={{ width: `${downloadTask.progress}%` }}
/>
</div>
<button
type="button"
onClick={cancelDownload}
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
cancel
</button>
</div>
) : downloadTask?.status === "complete" ? (
<p className="flex items-center gap-2 rounded-md border border-ctp-green/30 bg-ctp-green/10 px-3 py-2 text-sm text-ctp-green">
<CheckCircle2 className="h-4 w-4 shrink-0" />
Downloaded to the library
{downloadTask.library_j_id ? (
<Link
to={`/detail/${downloadTask.library_j_id}`}
className="font-mono text-ctp-blue hover:underline"
>
{downloadTask.library_j_id}
</Link>
) : null}
</p>
) : downloadTask?.status === "cancelled" ? (
<p className="text-xs text-ctp-overlay0">Download cancelled.</p>
) : downloadTask?.status === "error" ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{downloadTask.error || "Download failed."}
</p>
) : remoteFinishedTask && !downloadMutation.isPending ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-ctp-green" />
Your last download for this post finished
{remoteFinishedTask.library_j_id ? (
<>
{" — "}
<Link
to={`/detail/${remoteFinishedTask.library_j_id}`}
className="font-mono text-ctp-blue hover:underline"
>
{remoteFinishedTask.library_j_id}
</Link>
</>
) : null}
</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}
followable
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}
</>
}
/>
);
}