"use client"; // The viewer for one archived social post. // // A SIBLING of TranscriptModal, deliberately not a generalization of it: // TranscriptModal is a transcript-follows-playhead component (16:9 reserved // layout, playhead auto-scroll, per-row seek buttons, clip markers) and none of // that applies to a post. This renders the post, its thread context and its // outbound links, and nothing else. import { useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { XIcon } from "lucide-react"; import { Button } from "./ui/button"; import { useUrlParams, writeUrlParams } from "./urlState"; import { fetchPost, fetchThread } from "./postsCache"; import { postPlatformLabel, type Post } from "../lib/posts"; function formatWhen(iso: string): string { const ms = Date.parse(iso); if (!Number.isFinite(ms)) return iso; const d = new Date(ms); return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } function platformLabel(platform: Post["platform"]): string { return postPlatformLabel(platform); } // A post's capture (a screenshot and its attached media, taken by the // editor's capture-posts job), as the page that renders this modal serves it. // EDITOR-ONLY: the files live beside the channel (`posts-media//`) and are // never published, so the export never passes `loadCapture` and never shows // any of this. export type PostCaptureView = { capturedAt: string; // The capture's state ("captured", "deleted", …), as recorded. state: string; // The post sat behind a sensitive-media cover when it was shot. sensitive?: boolean; shot?: { src: string }; media: { src: string; name: string }[]; // The X Article the post links to, when the capture opened it: its title, // how that went, and its markdown when saved. article?: { state: string; title?: string; md?: { src: string } }; }; export type PostModalProps = { // Where the editor reads a post's capture from. Absent (the export): no // capture is looked for or shown. loadCapture?: (post: Post) => Promise; }; export default function PostModal({ loadCapture }: PostModalProps = {}) { const { v: slug, vm } = useUrlParams(); const open = vm === "post" && !!slug; const post = useQuery({ queryKey: ["post", slug], queryFn: () => fetchPost(slug!), enabled: open, }); const capture = useQuery({ queryKey: ["post-capture", slug], queryFn: () => loadCapture!(post.data!), enabled: open && !!loadCapture && !!post.data, }); const thread = useQuery({ queryKey: ["post-thread", slug], queryFn: () => fetchThread(slug!), enabled: open, }); const close = () => writeUrlParams({ v: null, t: null }); // Escape closes, matching the transcript modal's behavior. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); if (!open) return null; return (
{ if (e.target === e.currentTarget) close(); }} >
{post.data ? platformLabel(post.data.platform) : "Post"}
{post.isPending && (

Loading…

)} {post.isError && (

Could not load this post.

)} {post.data && (
{/* Thread context: the archived parent + replies around this post. The post-corpus analogue of a transcript's surrounding cues. */} {thread.data && thread.data.length > 1 && (

Thread ({thread.data.length} posts)

    {thread.data.map((p) => (
  1. ))}
)}
)}
); } function PostBody({ post, primary = false, compact = false, capture, }: { post: Post; primary?: boolean; compact?: boolean; capture?: PostCaptureView | null; }) { const links = useMemo(() => post.links ?? [], [post.links]); const capturedMedia = capture?.media.length ?? 0; return (
{post.authorName || post.author} {post.authorName && @{post.author}} · {post.isRepost && repost} {/* Every forum post after the first answers the thread; the badge would be on all of them. Its place in the thread says more. */} {post.isReply && !post.forum && reply} {post.forum?.position && #{post.forum.position}} {post.forum?.editedAt && ( edited )} {/* The whole point of archiving: this post no longer exists upstream. */} {post.isDeleted && ( deleted )}

{post.text}

{capture && } {post.forum?.threadTitle && !compact && (

In thread: {post.forum.threadTitle} {post.forum.page ? ` · page ${post.forum.page}` : ""}

)} {post.media && post.media.length > 0 && !capturedMedia && (
    {post.media.map((m) => (
  • {m.kind} {m.provider ? ` (${m.provider})` : ""}:{" "} {/^https?:\/\//.test(m.url) ? ( {m.name || m.url} ) : ( {m.name || m.url} )}
  • ))}
)} {links.length > 0 && ( )}
{post.isDeleted ? "Original (deleted)" : "Open original"} {post.engagement?.likes != null && ( {post.engagement.likes} likes )} {post.engagement?.reposts != null && ( {post.engagement.reposts} reposts )} {post.engagement?.replies != null && ( {post.engagement.replies} replies )} {capturedMedia > 0 ? ( {capturedMedia} media (captured) ) : post.mediaCount ? ( {post.mediaCount} media (not archived) ) : null}
); } function Badge({ children }: { children: React.ReactNode }) { return ( {children} ); } // What a captured file is, by its extension. export function captureMediaKind(name: string): "image" | "video" | "other" { const ext = name.toLowerCase().split(".").pop() ?? ""; if (["jpg", "jpeg", "png", "gif", "webp", "avif"].includes(ext)) return "image"; if (["mp4", "webm", "mov", "m4v"].includes(ext)) return "video"; return "other"; } // The captured screenshot and media of one post, and a line for its article. // Nothing when the capture holds none of them (a deleted post's record, say). export function PostCapturePanel({ capture }: { capture: PostCaptureView }) { const { article } = capture; if (!capture.shot && capture.media.length === 0 && !article) return null; return (

Captured {formatWhen(capture.capturedAt)} {capture.sensitive ? " · behind a sensitive-media cover" : ""}

{article && (

Article: {article.title ?? "untitled"} {article.state !== "captured" ? ` (${article.state})` : ""} {article.md && ( <> {" · "} article.md )}

)} {capture.shot && ( Screenshot of the post as captured )} {capture.media.length > 0 && (
    {capture.media.map((m) => { const kind = captureMediaKind(m.name); return (
  • {kind === "image" ? ( {m.name} ) : kind === "video" ? (
  • ); })}
)}
); }