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 = { s: "text-ctp-green", q: "text-ctp-peach", e: "text-ctp-red", }; const relatedBorder: Record = { 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; } 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("/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(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>( `/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) => api("/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(`/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 (
); } if (query.isError || !post) { return ( Back to the online browser ) } /> ); } 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 ( Online / Post #{post.id} } actions={ <>
{libraryJId ? ( In library ({libraryJId}) ) : ( )} {clientDownloadUrl ? ( Download to client ) : null} View on e621
{downloadTask && (downloadTask.status === "pending" || downloadTask.status === "downloading") ? (
Downloading to the library… {downloadTask.progress}% {formatBytes(downloadTask.downloaded)} {downloadTask.total > 0 ? ` / ${formatBytes(downloadTask.total)}` : ""} {downloadTask.speed ? ` · ${(downloadTask.speed / 1024 / 1024).toFixed(1)} MB/s` : ""}
) : downloadTask?.status === "complete" ? (

Downloaded to the library {downloadTask.library_j_id ? ( {downloadTask.library_j_id} ) : null}

) : downloadTask?.status === "cancelled" ? (

Download cancelled.

) : downloadTask?.status === "error" ? (

{downloadTask.error || "Download failed."}

) : remoteFinishedTask && !downloadMutation.isPending ? (

Your last download for this post finished {remoteFinishedTask.library_j_id ? ( <> {" — "} {remoteFinishedTask.library_j_id} ) : null}

) : null} } media={
{isVideo ? (
} aside={ <>

Tags

{TAG_CATEGORY_ORDER.map((category) => { const tags = post.tags[category] ?? []; if (tags.length === 0) return null; return (

{TAG_CATEGORY_LABELS[category] ?? category}

{tags.map((tag) => ( navigate(`/online?tags=${encodeURIComponent(tag)}`) } /> ))}
); })}

Specs

Rating
{RATING_LABELS[post.rating] ?? post.rating}
Score
{post.score.up} ▲ / {post.score.down} ▼
Favorites
{post.fav_count}
Comments
{post.comment_count}
File
{post.file.ext} · {formatBytes(post.file.size)}
Dimensions
{post.file.width}×{post.file.height}
MD5
{post.file.md5}
Posted
{formatDate(post.created_at)}
{post.uploader_name ? ( <>
Uploader
{post.uploader_name}
) : null}
} footer={ <> {post.description.trim() ? (
Description
{renderDText(post.description, credentials?.base_url)}
) : null} {post.sources.length > 0 ? (

Sources

    {post.sources.slice(0, 6).map((source) => (
  • {source}
  • ))}
) : null} {post.pools.length > 0 ? (

Pools

{post.pools.map((poolId) => ( Pool #{poolId} ))}
) : null} {relatedIds.length > 0 ? (

Related posts

{relatedQuery.isPending ? ( ) : (
{(relatedQuery.data ?? []).map((related) => (
{related.preview.url ? ( {`Post ) : null}

#{related.id}

))}
)}
) : null} } /> ); }