Archilyzer · Source

archilyzer

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

commit 17dfa7bb81085edcbf7fa79ee5ee2ba270041018
parent 8b7e03213d9d1afca29dae7decf33f2684aa0462
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 19 May 2026 21:58:09 -0400

Changelog UI

Diffstat:
Acommon/components/Changelog.tsx | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/CopyLinkButton.tsx | 46++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/changelog.ts | 20++++++++++++++++++++
Mcommon/package.json | 1+
Meditor/CHANGELOG.md | 4++++
Aeditor/app/changelog/page.tsx | 27+++++++++++++++++++++++++++
Aeditor/app/components/ChangelogNavLink.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/components/MarkChangelogSeen.tsx | 20++++++++++++++++++++
Meditor/app/layout.tsx | 35+++++++++++++++++++++++++++++++++--
Meditor/app/page.tsx | 31+++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 4++++
Aexport/app/changelog/page.tsx | 23+++++++++++++++++++++++
Mexport/app/layout.tsx | 10+++++++++-
Mpnpm-lock.yaml | 16++++++++++++++++
14 files changed, 417 insertions(+), 3 deletions(-)

diff --git a/common/components/Changelog.tsx b/common/components/Changelog.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from "react"; +import { Children } from "react"; +import Markdown from "markdown-to-jsx"; +import { sliceTopEntries } from "../lib/changelog"; +import { CopyLinkButton } from "./CopyLinkButton"; + +function flattenText(children: ReactNode): string { + let out = ""; + Children.forEach(children, (child) => { + if (child == null || typeof child === "boolean") return; + if (typeof child === "string" || typeof child === "number") { + out += String(child); + return; + } + if (typeof child === "object" && "props" in child) { + const props = (child as { props?: { children?: ReactNode } }).props; + if (props?.children !== undefined) out += flattenText(props.children); + } + }); + return out; +} + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; +const ISO_DATE = /(\d{4}-\d{2}-\d{2})/; + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function anchorFor(text: string): string { + if (DATE_ONLY.test(text)) return text; + const dateMatch = text.match(ISO_DATE); + if (dateMatch) return dateMatch[1]; + const slug = slugify(text); + return slug || "section"; +} + +function H2({ children }: { children?: ReactNode }) { + const text = flattenText(children).trim(); + const anchor = text ? anchorFor(text) : null; + return ( + <h2 + id={anchor ?? undefined} + className="scroll-mt-20 mt-8 mb-3 text-base font-semibold text-zinc-900 dark:text-zinc-100 first:mt-0 flex items-baseline" + > + <span>{children}</span> + {anchor && <CopyLinkButton anchor={anchor} />} + </h2> + ); +} + +function H1({ children }: { children?: ReactNode }) { + // The top-level `# Editor Changelog` heading is already conveyed by the + // page's own <h1>; render it as a no-op so the file stays a valid + // standalone document but the page doesn't get a duplicate title. + return <span className="hidden">{children}</span>; +} + +const OVERRIDES = { + h1: { component: H1 }, + h2: { component: H2 }, + h3: { + props: { + className: + "mt-6 mb-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100", + }, + }, + p: { + props: { className: "my-2 text-sm text-zinc-700 dark:text-zinc-300" }, + }, + ul: { + props: { + className: + "list-disc pl-5 my-2 space-y-1 text-sm text-zinc-700 dark:text-zinc-300 marker:text-zinc-400", + }, + }, + ol: { + props: { + className: + "list-decimal pl-5 my-2 space-y-1 text-sm text-zinc-700 dark:text-zinc-300 marker:text-zinc-400", + }, + }, + li: { props: { className: "leading-relaxed" } }, + strong: { + props: { + className: "font-semibold text-zinc-900 dark:text-zinc-100", + }, + }, + em: { props: { className: "italic" } }, + code: { + props: { + className: + "px-1 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 font-mono text-[0.85em]", + }, + }, + pre: { + props: { + className: + "my-3 p-3 rounded-md bg-zinc-100 dark:bg-zinc-900 overflow-x-auto text-xs font-mono", + }, + }, + a: { + props: { + className: + "underline decoration-zinc-400 hover:decoration-zinc-700 dark:hover:decoration-zinc-200", + }, + }, + hr: { + props: { + className: "my-6 border-t border-zinc-200 dark:border-zinc-800", + }, + }, + blockquote: { + props: { + className: + "my-3 pl-3 border-l-2 border-zinc-300 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400", + }, + }, +}; + +export function Changelog({ + source, + limit, +}: { + source: string; + limit?: number; +}) { + const sliced = limit ? sliceTopEntries(source, limit) : source; + return ( + <div className="changelog"> + <Markdown options={{ overrides: OVERRIDES, forceBlock: true }}> + {sliced} + </Markdown> + </div> + ); +} diff --git a/common/components/CopyLinkButton.tsx b/common/components/CopyLinkButton.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState } from "react"; + +export function CopyLinkButton({ anchor }: { anchor: string }) { + const [copied, setCopied] = useState(false); + + const onClick = async () => { + try { + const { origin, pathname } = window.location; + await navigator.clipboard.writeText(`${origin}${pathname}#${anchor}`); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } catch { + // clipboard write failed — silently no-op; user can still click the heading + } + }; + + return ( + <button + type="button" + onClick={onClick} + aria-label={`Copy link to ${anchor}`} + className="ml-2 align-middle inline-flex items-center gap-1 text-xs font-normal text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors" + > + {copied ? ( + <span aria-hidden="true">Copied</span> + ) : ( + <svg + aria-hidden="true" + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> + </svg> + )} + </button> + ); +} diff --git a/common/lib/changelog.ts b/common/lib/changelog.ts @@ -0,0 +1,20 @@ +// Matches the first ISO date on any `## ` heading line. Works for both the +// bare-date format (`## 2026-05-19`) and Keep-a-Changelog (`## [1.2.3] - 2026-05-19`). +const DATED_HEADING = /^##\s+[^\n]*?(\d{4}-\d{2}-\d{2})/m; + +const ANY_H2 = /^##\s+[^\n]+$/gm; + +export function getLatestChangelogDate(source: string): string | null { + const match = source.match(DATED_HEADING); + return match ? match[1] : null; +} + +export function sliceTopEntries(source: string, limit: number): string { + if (limit <= 0) return source; + const indexes: number[] = []; + for (const m of source.matchAll(ANY_H2)) { + indexes.push(m.index ?? 0); + } + if (indexes.length <= limit) return source; + return source.slice(0, indexes[limit]).trimEnd() + "\n"; +} diff --git a/common/package.json b/common/package.json @@ -10,6 +10,7 @@ "execa": "^9.6.1", "fs-extra": "^11.3.4", "lmdb": "^3.5.4", + "markdown-to-jsx": "^7.7.4", "p-limit": "^7.3.0", "react-player": "^2.16.1" }, diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- New `/changelog` page that renders the editor's `CHANGELOG.md`. Linked from the sidebar nav. +- "Recent changes" card on the dashboard showing the most recent changelog entry, with a "View all" link to the full changelog. +- Subtle blue dot on the sidebar **Changelog** nav item when a newer dated entry is detected and hasn't been viewed yet. The state is per-browser (localStorage) and clears when the changelog page is opened. +- Per-heading copy-link buttons on the changelog page for permalinks to any section. - Per-channel "Include in Sync all" toggle on the `/channels` list. Excluded channels are skipped by **Sync all** and surfaced in the existing skipped tooltip with reason "excluded from sync all". Stored as `excludeFromSync` in `channels/<slug>/config.json`. - Sortable column headers on the `/channels` table. Clicking any column sorts client-side; clicking the active column flips direction. Default rendering is unsorted (input order preserved); sort state does not persist across navigation. diff --git a/editor/app/changelog/page.tsx b/editor/app/changelog/page.tsx @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import type { Metadata } from "next"; +import { Changelog } from "yt-dlp-transcript-common/components/Changelog"; +import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog"; +import { MarkChangelogSeen } from "../components/MarkChangelogSeen"; + +export const metadata: Metadata = { title: "Changelog" }; + +function loadChangelog(): string { + return readFileSync( + path.join(process.cwd(), "CHANGELOG.md"), + "utf8", + ); +} + +export default function ChangelogPage() { + const source = loadChangelog(); + const latestDate = getLatestChangelogDate(source); + return ( + <div className="flex flex-col gap-4 max-w-3xl"> + <h1 className="text-2xl font-semibold">Changelog</h1> + <Changelog source={source} /> + <MarkChangelogSeen latestDate={latestDate} /> + </div> + ); +} diff --git a/editor/app/components/ChangelogNavLink.tsx b/editor/app/components/ChangelogNavLink.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; + +const STORAGE_KEY = "changelogLastSeen"; + +export function ChangelogNavLink({ + href, + label, + latestDate, + className, +}: { + href: string; + label: string; + latestDate: string | null; + className?: string; +}) { + const [unread, setUnread] = useState(false); + + useEffect(() => { + if (!latestDate) return; + try { + const lastSeen = window.localStorage.getItem(STORAGE_KEY); + setUnread(!lastSeen || lastSeen < latestDate); + } catch { + // localStorage unavailable — leave unread false + } + }, [latestDate]); + + return ( + <Link href={href} className={className}> + <span>{label}</span> + {unread && ( + <span + aria-label="New changelog entry" + title="New changelog entry" + className="ml-auto inline-block w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400" + /> + )} + </Link> + ); +} diff --git a/editor/app/components/MarkChangelogSeen.tsx b/editor/app/components/MarkChangelogSeen.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +const STORAGE_KEY = "changelogLastSeen"; + +export function MarkChangelogSeen({ latestDate }: { latestDate: string | null }) { + useEffect(() => { + if (!latestDate) return; + try { + const prev = window.localStorage.getItem(STORAGE_KEY); + if (!prev || prev < latestDate) { + window.localStorage.setItem(STORAGE_KEY, latestDate); + } + } catch { + // localStorage unavailable — no-op + } + }, [latestDate]); + return null; +} diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -1,9 +1,13 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; import type { Metadata } from "next"; import { Suspense } from "react"; import Link from "next/link"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog"; import { CommandPalette } from "./components/CommandPalette"; +import { ChangelogNavLink } from "./components/ChangelogNavLink"; import "./globals.css"; export async function generateMetadata(): Promise<Metadata> { @@ -21,7 +25,7 @@ export async function generateMetadata(): Promise<Metadata> { type NavLink = { href: string; label: string; - badgeKey?: "jobs"; + badgeKey?: "jobs" | "changelog"; }; const NAV_LINKS: NavLink[] = [ @@ -31,8 +35,21 @@ const NAV_LINKS: NavLink[] = [ { href: "/jobs/active", label: "Active", badgeKey: "jobs" }, { href: "/build", label: "Build" }, { href: "/settings", label: "Settings" }, + { href: "/changelog", label: "Changelog", badgeKey: "changelog" }, ]; +function readChangelogLatestDate(): string | null { + try { + const source = readFileSync( + path.join(process.cwd(), "CHANGELOG.md"), + "utf8", + ); + return getLatestChangelogDate(source); + } catch { + return null; + } +} + export default async function RootLayout({ children, }: Readonly<{ @@ -43,6 +60,7 @@ export default async function RootLayout({ const activeJobs = getRegistry() .list() .filter((j) => j.status === "running" || j.status === "queued").length; + const latestChangelogDate = readChangelogLatestDate(); return ( <html lang="en" className="h-full antialiased"> <body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> @@ -60,13 +78,26 @@ export default async function RootLayout({ </div> <nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible"> {NAV_LINKS.map((link) => { + const navItemClass = + "px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap flex items-center gap-2"; + if (link.badgeKey === "changelog") { + return ( + <ChangelogNavLink + key={link.href} + href={link.href} + label={link.label} + latestDate={latestChangelogDate} + className={navItemClass} + /> + ); + } const showBadge = link.badgeKey === "jobs" && activeJobs > 0; return ( <Link key={link.href} href={link.href} - className="px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap flex items-center gap-2" + className={navItemClass} > <span>{link.label}</span> {showBadge && ( diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { stat } from "node:fs/promises"; import path from "node:path"; import type { Metadata } from "next"; @@ -6,6 +7,7 @@ import { listChannels } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { Changelog } from "yt-dlp-transcript-common/components/Changelog"; export const dynamic = "force-dynamic"; @@ -30,6 +32,17 @@ async function getLastBuildTime(paths: ReturnType<typeof getPaths>) { } } +function loadChangelog(): string | null { + try { + return readFileSync( + path.join(process.cwd(), "CHANGELOG.md"), + "utf8", + ); + } catch { + return null; + } +} + export default async function Dashboard() { const paths = getPaths(); const channels = await listChannels(paths); @@ -38,6 +51,7 @@ export default async function Dashboard() { const runningJobs = getRegistry() .list() .filter((j) => j.status === "running" || j.status === "queued"); + const changelogSource = loadChangelog(); return ( <div className="flex flex-col gap-6"> @@ -56,6 +70,23 @@ export default async function Dashboard() { /> </dl> + {changelogSource && ( + <section className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"> + <div className="flex items-baseline justify-between gap-3 mb-1"> + <h2 className="text-xs uppercase tracking-wide text-zinc-500"> + Recent changes + </h2> + <Link + href="/changelog" + className="text-xs text-zinc-500 underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + View all → + </Link> + </div> + <Changelog source={changelogSource} limit={1} /> + </section> + )} + {channels.length > 0 && ( <section className="flex flex-col gap-3"> <h2 className="text-lg font-semibold">Channels</h2> diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -6,3 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Added +- New `/changelog` page that renders the export's `CHANGELOG.md`. Linked from the right side of the sticky header. +- Per-heading copy-link buttons on the changelog page for permalinks to any section. diff --git a/export/app/changelog/page.tsx b/export/app/changelog/page.tsx @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import type { Metadata } from "next"; +import { Changelog } from "yt-dlp-transcript-common/components/Changelog"; + +export const metadata: Metadata = { title: "Changelog" }; + +function loadChangelog(): string { + return readFileSync( + path.join(process.cwd(), "CHANGELOG.md"), + "utf8", + ); +} + +export default function ChangelogPage() { + const source = loadChangelog(); + return ( + <div className="flex flex-col gap-4 max-w-3xl"> + <h1 className="text-2xl font-semibold">Changelog</h1> + <Changelog source={source} /> + </div> + ); +} diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -41,10 +41,18 @@ export default async function RootLayout({ <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans"> <QueryProvider> <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20"> - <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between"> + <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-3"> <Link href="/" className="font-semibold tracking-tight"> {settings.headerTitle} </Link> + <nav className="text-sm"> + <Link + href="/changelog" + className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100" + > + Changelog + </Link> + </nav> </div> </header> <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6"> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: lmdb: specifier: ^3.5.4 version: 3.5.4 + markdown-to-jsx: + specifier: ^7.7.4 + version: 7.7.17(react@19.2.4) next: specifier: 16.2.3 version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2091,6 +2094,15 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-to-jsx@7.7.17: + resolution: {integrity: sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==} + engines: {node: '>= 10'} + peerDependencies: + react: '>= 0.14.0' + peerDependenciesMeta: + react: + optional: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4711,6 +4723,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-to-jsx@7.7.17(react@19.2.4): + optionalDependencies: + react: 19.2.4 + math-intrinsics@1.1.0: {} memoize-one@5.2.1: {}