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:
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: {}