Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 280c5b61b94e87a98cc0f92b74293416f85d8ef4
parent 8180a19bff3a3744cb58acf054ead19c33749fda
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:15:51 -0400

common: PostModal shows a post's capture behind an editor-only prop

PostModal takes an optional loadCapture; with it, the post shows its
captured screenshot and media (images, videos, other files as links) and
counts its media as captured. The export never passes it, so it never
looks for or shows a capture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/components/PostModal.test.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/PostModal.tsx | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 151 insertions(+), 3 deletions(-)

diff --git a/common/components/PostModal.test.ts b/common/components/PostModal.test.ts @@ -0,0 +1,63 @@ +// The post viewer's capture panel: shown only from a capture the editor hands +// it, with the shot and each medium in its own element; nothing without one. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test components/PostModal.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + captureMediaKind, + PostCapturePanel, + type PostCaptureView, +} from "./PostModal"; + +// common's tsconfig has `jsx: "preserve"`, so tsx falls back to the classic +// transform — `React.createElement` on a free `React` (as BrandMark.test.ts). +(globalThis as { React?: typeof React }).React = React; +const render = (capture: PostCaptureView) => + renderToStaticMarkup(React.createElement(PostCapturePanel, { capture })); + +const BASE: PostCaptureView = { + capturedAt: "2026-02-03T04:05:06.000Z", + state: "captured", + media: [], +}; + +test("with media: the shot, each image and video in its own element, the sensitive note", () => { + const html = render({ + ...BASE, + sensitive: true, + shot: { src: "/capture/demo/1/shot.png" }, + media: [ + { src: "/capture/demo/1/1_1.jpg", name: "1_1.jpg" }, + { src: "/capture/demo/1/1_2.mp4", name: "1_2.mp4" }, + { src: "/capture/demo/1/1_3.bin", name: "1_3.bin" }, + ], + }); + assert.match(html, /data-post-capture=""/); + assert.match(html, /<img src="\/capture\/demo\/1\/shot\.png"[^>]*data-post-capture-shot=""/); + assert.match(html, /data-post-capture-media="image"><img src="\/capture\/demo\/1\/1_1\.jpg"/); + assert.match(html, /data-post-capture-media="video"><video src="\/capture\/demo\/1\/1_2\.mp4"/); + assert.match(html, /data-post-capture-media="other"><a href="\/capture\/demo\/1\/1_3\.bin"/); + assert.match(html, /behind a sensitive-media cover/); +}); + +test("a shot with no media shows the shot alone", () => { + const html = render({ ...BASE, shot: { src: "/s.png" } }); + assert.match(html, /data-post-capture-shot/); + assert.doesNotMatch(html, /data-post-capture-media/); + assert.doesNotMatch(html, /sensitive/); +}); + +test("without media or a shot (a deleted post's record): nothing at all", () => { + assert.equal(render({ ...BASE, state: "deleted" }), ""); +}); + +test("media kinds by extension", () => { + assert.equal(captureMediaKind("a.JPG"), "image"); + assert.equal(captureMediaKind("a.webp"), "image"); + assert.equal(captureMediaKind("a.mp4"), "video"); + assert.equal(captureMediaKind("a"), "other"); +}); diff --git a/common/components/PostModal.tsx b/common/components/PostModal.tsx @@ -33,7 +33,28 @@ function platformLabel(platform: Post["platform"]): string { return platform === "twitter" ? "X" : "Bluesky"; } -export default function PostModal() { +// 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/<id>/`) 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 }[]; +}; + +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<PostCaptureView | null>; +}; + +export default function PostModal({ loadCapture }: PostModalProps = {}) { const { v: slug, vm } = useUrlParams(); const open = vm === "post" && !!slug; @@ -42,6 +63,11 @@ export default function PostModal() { 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!), @@ -101,7 +127,7 @@ export default function PostModal() { {post.data && ( <div className="flex flex-col gap-4 px-4 py-4"> - <PostBody post={post.data} primary /> + <PostBody post={post.data} primary capture={capture.data} /> {/* Thread context: the archived parent + replies around this post. The post-corpus analogue of a transcript's surrounding cues. */} @@ -130,12 +156,15 @@ 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 ( <article data-post-id={post.id} @@ -173,6 +202,8 @@ function PostBody({ {post.text} </p> + {capture && <PostCapturePanel capture={capture} />} + {links.length > 0 && ( <ul className="mt-2 flex flex-col gap-1"> {links.map((href) => ( @@ -208,7 +239,11 @@ function PostBody({ {post.engagement?.replies != null && ( <span>{post.engagement.replies} replies</span> )} - {post.mediaCount ? <span>{post.mediaCount} media (not archived)</span> : null} + {capturedMedia > 0 ? ( + <span>{capturedMedia} media (captured)</span> + ) : post.mediaCount ? ( + <span>{post.mediaCount} media (not archived)</span> + ) : null} </footer> </article> ); @@ -221,3 +256,53 @@ function Badge({ children }: { children: React.ReactNode }) { </span> ); } + +// 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. Nothing when the capture +// holds neither (a deleted post's record, say). +export function PostCapturePanel({ capture }: { capture: PostCaptureView }) { + if (!capture.shot && capture.media.length === 0) return null; + return ( + <section data-post-capture="" className="mt-3 flex flex-col gap-2"> + <h3 className="text-xs uppercase tracking-wide text-muted-foreground"> + Captured {formatWhen(capture.capturedAt)} + {capture.sensitive ? " · behind a sensitive-media cover" : ""} + </h3> + {capture.shot && ( + <img + src={capture.shot.src} + alt="Screenshot of the post as captured" + data-post-capture-shot="" + className="max-w-full rounded-md border border-border" + /> + )} + {capture.media.length > 0 && ( + <ul className="flex flex-col gap-2"> + {capture.media.map((m) => { + const kind = captureMediaKind(m.name); + return ( + <li key={m.src} data-post-capture-media={kind}> + {kind === "image" ? ( + <img src={m.src} alt={m.name} className="max-w-full rounded-md" /> + ) : kind === "video" ? ( + <video src={m.src} controls preload="metadata" className="max-w-full rounded-md" /> + ) : ( + <a href={m.src} className="text-xs text-primary underline break-all"> + {m.name} + </a> + )} + </li> + ); + })} + </ul> + )} + </section> + ); +}