commit 8cd04b1b9e220effdd4d13542ecd298dd7906224
parent 6b6e79cfdc79a17c4c2859d560db451bb69482d3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 28 Jun 2026 21:55:44 -0400
Merge feat/hub-cross-site-landing: cross-site landing hub + drop Markdown pages
Diffstat:
29 files changed, 1433 insertions(+), 894 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -63,6 +63,7 @@ yarn-error.log*
/homepage/public/stats/
/homepage/public/channel-sites.json
/homepage/public/chart-templates.json
+/homepage/public/homepage-summary.json
# editor e2e fixtures and ephemeral state
/editor/test-transcripts/
diff --git a/common/bin/compose-homepage.ts b/common/bin/compose-homepage.ts
@@ -1,22 +1,30 @@
#!/usr/bin/env tsx
// Composes the served `public/` dir for the Archilyzer hub (homepage package)
// ahead of `next build`. Unlike compose-site.ts this is a SINGLE, pool-wide
-// build — there is no SITE_ID and no per-site filtering. It produces only the
-// two data files the hub's cross-site charts need:
+// build — there is no SITE_ID and no per-site filtering. It produces the data
+// files the hub's cross-site landing needs:
//
// public/stats/{manifest,page-NNNN}.json <- whole-pool stats (every channel)
// public/channel-sites.json <- channel slug -> [siteId, ...]
+// public/homepage-summary.json <- small cross-site landing summary
//
-// The hub's Markdown pages and chart-templates are read straight from the data
-// dir by the homepage app at build (listHomepagePages / readHomepageTemplates),
-// so they are not staged here. Requires build:index to have populated the cues
-// LMDB first (the homepage prebuild chains it), same as the export pipeline.
+// Requires build:index to have populated the cues LMDB first (the homepage
+// prebuild chains it), same as the export pipeline.
import path from "node:path";
-import { mkdir, rename, writeFile } from "node:fs/promises";
+import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { getPaths } from "../lib/paths";
import { buildStats } from "../controller/buildStats";
import { listSites } from "../lib/site";
+import {
+ statsPageFileName,
+ type StatsManifest,
+ type VideoStat,
+} from "../lib/stats";
+import {
+ buildHomepageSummary,
+ type ChannelSitesMap,
+} from "../lib/homepageSummary";
// Where the homepage Next.js app serves static assets from. Overridable for e2e
// test isolation, mirroring EXPORT_PUBLIC_DIR.
@@ -33,6 +41,28 @@ async function writeJsonAtomic(filePath: string, value: unknown): Promise<void>
await rename(tmp, filePath);
}
+// Read the whole-pool stats dataset back from the pages buildStats just wrote, so
+// the lightweight homepage summary can be derived from the same records without
+// re-extracting. Pages are local files (fast) capped well under memory limits.
+async function readStatsPages(statsDir: string): Promise<VideoStat[]> {
+ let manifest: StatsManifest;
+ try {
+ manifest = JSON.parse(
+ await readFile(path.join(statsDir, "manifest.json"), "utf8"),
+ ) as StatsManifest;
+ } catch {
+ return [];
+ }
+ const out: VideoStat[] = [];
+ for (let i = 0; i < manifest.pageCount; i++) {
+ const page = JSON.parse(
+ await readFile(path.join(statsDir, statsPageFileName(i)), "utf8"),
+ ) as VideoStat[];
+ out.push(...page);
+ }
+ return out;
+}
+
async function main(): Promise<void> {
const paths = getPaths();
const publicDir = homepagePublicDir(paths.monorepoRoot);
@@ -48,7 +78,7 @@ async function main(): Promise<void> {
// `groupBy: "site"` in the hub's dashboard (see channelSites.tsx). A channel
// on multiple sites maps to all of them; a pool-only channel is simply absent.
const sites = listSites(paths);
- const channelSites: Record<string, string[]> = {};
+ const channelSites: ChannelSitesMap = {};
for (const site of sites) {
for (const c of site.channels) {
(channelSites[c.slug] ??= []).push(site.siteId);
@@ -59,8 +89,20 @@ async function main(): Promise<void> {
channelSites,
);
+ // Lightweight cross-site summary for the hub landing page (pre-binned monthly
+ // counts, per-site totals/sparklines, recent additions). Embedded into the
+ // SSG HTML so the front page never fetches the full stats dataset.
+ const stats = await readStatsPages(statsDir);
+ const summary = buildHomepageSummary(stats, channelSites, sites, new Date());
+ await writeJsonAtomic(
+ path.join(publicDir, "homepage-summary.json"),
+ summary,
+ );
+
console.log(
- `compose-homepage: ${Object.keys(channelSites).length} channel(s) mapped across ${sites.length} site(s) into ${publicDir}.`,
+ `compose-homepage: ${Object.keys(channelSites).length} channel(s) mapped across ${sites.length} site(s); ` +
+ `summary covers ${summary.totals.transcripts} transcription(s) / ${summary.totals.downloads} download(s) ` +
+ `across ${summary.sites.length} public site(s) into ${publicDir}.`,
);
}
diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useMemo } from "react";
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ XAxis,
+ YAxis,
+} from "recharts";
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "../ui/chart";
+import type { BucketSeries, HomepageSummary } from "../../lib/homepageSummary";
+import {
+ seriesColor,
+ OTHER_COLOR,
+ weekLabel,
+ monthLabel,
+ type Breakdown,
+ type Bucket,
+ type Range,
+ type Metric,
+ type Display,
+} from "../../lib/homepageChart";
+
+const TOP_CHANNELS = 8;
+
+// How many trailing buckets each range shows, per granularity.
+function windowSize(range: Range, weekly: boolean): number | null {
+ if (range === "all") return null;
+ if (range === "12mo") return weekly ? 52 : 12;
+ return weekly ? 13 : 3; // 90d
+}
+
+function runningSum(v: number[]): number[] {
+ let acc = 0;
+ return v.map((x) => (acc += x));
+}
+
+type Series = { id: string; label: string; color: string; values: number[] };
+
+export function CrossSiteChart({
+ summary,
+ breakdown,
+ bucket,
+ range,
+ metric,
+ display,
+ hidden,
+}: {
+ summary: HomepageSummary;
+ breakdown: Breakdown;
+ bucket: Bucket;
+ range: Range;
+ metric: Metric;
+ display: Display;
+ hidden: ReadonlySet<string>;
+}) {
+ const { rows, series, labels } = useMemo(() => {
+ const ms = summary.series[metric];
+ const weekly = bucket !== "month"; // week + cumulative bin weekly
+ const src: BucketSeries = weekly ? ms.week : ms.month;
+ const cumulative = bucket === "cumulative";
+ const labelOf = weekly ? weekLabel : monthLabel;
+
+ const win = windowSize(range, weekly);
+ const start = win == null ? 0 : Math.max(0, src.buckets.length - win);
+ const len = src.buckets.length;
+ const pad = (vals?: number[]) =>
+ Array.from({ length: len }, (_, i) => vals?.[i] ?? 0);
+ const transform = (vals?: number[]) => {
+ const full = pad(vals);
+ return (cumulative ? runningSum(full) : full).slice(start);
+ };
+
+ let series: Series[];
+ if (breakdown === "site") {
+ series = summary.sites
+ .map((s, i) => ({ s, color: seriesColor(i) }))
+ .filter(({ s }) => !hidden.has(s.siteId))
+ .map(({ s, color }) => ({
+ id: s.siteId,
+ label: s.siteTitle,
+ color,
+ values: transform(src.bySite[s.siteId]),
+ }));
+ } else {
+ const nameOf = new Map(summary.channels.map((c) => [c.slug, c.name]));
+ const totalOf = (slug: string) =>
+ (src.byChannel[slug] ?? []).reduce((a, b) => a + b, 0);
+ const sorted = Object.keys(src.byChannel).sort(
+ (a, b) => totalOf(b) - totalOf(a) || a.localeCompare(b),
+ );
+ const top = sorted.slice(0, TOP_CHANNELS);
+ const rest = sorted.slice(TOP_CHANNELS);
+ series = top.map((slug, i) => ({
+ id: slug,
+ label: nameOf.get(slug) ?? slug,
+ color: seriesColor(i),
+ values: transform(src.byChannel[slug]),
+ }));
+ if (rest.length) {
+ const otherFull = pad();
+ for (const slug of rest) {
+ const p = pad(src.byChannel[slug]);
+ for (let i = 0; i < p.length; i++) otherFull[i] += p[i];
+ }
+ series.push({
+ id: "__other",
+ label: `Other (${rest.length})`,
+ color: OTHER_COLOR,
+ values: (cumulative ? runningSum(otherFull) : otherFull).slice(start),
+ });
+ }
+ }
+
+ const labels = src.buckets.slice(start).map(labelOf);
+ const rows = labels.map((x, i) => {
+ const row: Record<string, string | number> = { x };
+ for (const s of series) row[s.id] = s.values[i] ?? 0;
+ return row;
+ });
+ return { rows, series, labels };
+ }, [summary, breakdown, bucket, range, metric, hidden]);
+
+ const config = useMemo<ChartConfig>(() => {
+ const c: ChartConfig = {};
+ for (const s of series) c[s.id] = { label: s.label, color: s.color };
+ return c;
+ }, [series]);
+
+ if (labels.length === 0) {
+ return (
+ <div className="flex h-[320px] items-center justify-center rounded-xl border border-zinc-200 dark:border-zinc-800 text-sm text-zinc-500">
+ No data for this metric yet.
+ </div>
+ );
+ }
+ if (series.length === 0) {
+ return (
+ <div className="flex h-[320px] items-center justify-center rounded-xl border border-zinc-200 dark:border-zinc-800 text-sm text-zinc-500">
+ All sites hidden — re-enable one from the cards below.
+ </div>
+ );
+ }
+
+ const isArea = bucket === "cumulative";
+ const isShare = display === "share";
+ const stackOffset = isShare ? "expand" : undefined;
+ const showLegend = breakdown === "channel" && series.length > 1;
+ const margin = { top: 8, right: 12, bottom: 4, left: 4 };
+
+ const axes = [
+ <CartesianGrid key="grid" vertical={false} strokeDasharray="3 3" />,
+ <XAxis
+ key="x"
+ dataKey="x"
+ tickLine={false}
+ axisLine={false}
+ tickMargin={8}
+ minTickGap={24}
+ />,
+ <YAxis
+ key="y"
+ tickLine={false}
+ axisLine={false}
+ width={isShare ? 40 : 44}
+ // Share = 100% stacked: pin the domain so the stack fills the height (and
+ // give it fractional ticks). Counts: integer auto-scale.
+ domain={isShare ? [0, 1] : undefined}
+ ticks={isShare ? [0, 0.25, 0.5, 0.75, 1] : undefined}
+ allowDecimals={isShare ? true : false}
+ tickFormatter={
+ isShare ? (v: number) => `${Math.round(v * 100)}%` : undefined
+ }
+ />,
+ <ChartTooltip key="tip" content={<ChartTooltipContent />} />,
+ showLegend ? (
+ <ChartLegend key="leg" content={<ChartLegendContent />} />
+ ) : null,
+ ];
+
+ return (
+ <ChartContainer
+ config={config}
+ className="aspect-auto h-[320px] w-full sm:h-[380px]"
+ >
+ {isArea ? (
+ <AreaChart data={rows} margin={margin} stackOffset={stackOffset} accessibilityLayer>
+ {axes}
+ {series.map((s) => (
+ <Area
+ key={s.id}
+ dataKey={s.id}
+ name={s.label}
+ type="monotone"
+ stroke={s.color}
+ fill={s.color}
+ fillOpacity={0.25}
+ strokeWidth={1.5}
+ stackId="a"
+ isAnimationActive={false}
+ />
+ ))}
+ </AreaChart>
+ ) : (
+ <BarChart data={rows} margin={margin} stackOffset={stackOffset} accessibilityLayer>
+ {axes}
+ {series.map((s) => (
+ <Bar
+ key={s.id}
+ dataKey={s.id}
+ name={s.label}
+ fill={s.color}
+ stackId="a"
+ isAnimationActive={false}
+ />
+ ))}
+ </BarChart>
+ )}
+ </ChartContainer>
+ );
+}
diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts
@@ -7,28 +7,15 @@ import {
type SiteSettings,
type SocialLink,
} from "./settings";
-import {
- isValidHomepageSlug,
- type HomepageNavItem,
-} from "./homepageConstants";
-
-// Re-export the pure constants/types so server callers can keep importing them
-// from "lib/homepage"; client components must import from "lib/homepageConstants"
-// directly to stay fs-free.
-export {
- HOMEPAGE_INDEX_SLUG,
- isValidHomepageSlug,
- type HomepageNavItem,
-} from "./homepageConstants";
-// The Archilyzer hub/homepage is a SINGLE, instance-level marketing + docs site
-// (the `homepage` SSG package) that sits above the per-content sites. Unlike a
-// Site, it has no channel membership and no grouping layout — it's just branding,
-// social links, an ordered nav of Markdown pages (bodies live as files under
-// homepagePagesDir, see homepagePages.ts) and an optional cross-site chart
-// dashboard (chart-templates.json, same shape sites use). Config lives at
-// homepageConfigFile (sites/_homepage/homepage.json); operational config stays
-// global in settings.json.
+// The Archilyzer hub/homepage is a SINGLE, instance-level landing site (the
+// `homepage` SSG package) that sits above the per-content sites. Unlike a Site,
+// it has no channel membership and no grouping layout — it's just branding,
+// social links, and an optional cross-site chart dashboard (chart-templates.json,
+// same shape sites use); the home route renders a cross-site stats landing built
+// from the whole channel pool. Config lives at homepageConfigFile
+// (sites/_homepage/homepage.json); operational config stays global in
+// settings.json.
export type HomepageConfig = {
siteTitle: string;
@@ -39,9 +26,6 @@ export type HomepageConfig = {
// SiteSettings.socialLinks; an array (even empty) overrides it. Same semantics
// as Site.socialLinks — resolve with resolveHomepageSocialLinks() at render.
socialLinks?: SocialLink[];
- // Ordered nav of Markdown pages. The home route ("/") renders the page with
- // slug "index" when present; every other slug renders at "/<slug>".
- nav: HomepageNavItem[];
// Absolute public URL of the deployed hub, e.g. "https://archilyzer.com".
siteUrl?: string;
// Cloudflare Pages project the hub deploys to.
@@ -54,39 +38,9 @@ function defaults(): HomepageConfig {
siteDescription: "Searchable video-transcript archives.",
headerTitle: "Archilyzer",
homeTagline: "",
- nav: [],
};
}
-export function parseHomepageNav(input: unknown): HomepageNavItem[] {
- if (!Array.isArray(input)) return [];
- const out: HomepageNavItem[] = [];
- const seen = new Set<string>();
- for (const raw of input) {
- if (!raw || typeof raw !== "object") continue;
- const r = raw as Record<string, unknown>;
- const slug = typeof r.slug === "string" ? r.slug.trim() : "";
- if (!isValidHomepageSlug(slug) || seen.has(slug)) continue;
- seen.add(slug);
- const label =
- typeof r.label === "string" && r.label.trim() ? r.label.trim() : slug;
- const item: HomepageNavItem = { slug, label };
- if (typeof r.order === "number" && Number.isFinite(r.order)) {
- item.order = Math.floor(r.order);
- }
- out.push(item);
- }
- // Stable display order: explicit `order` first (ascending), then config order.
- return out
- .map((item, i) => ({ item, i }))
- .sort((a, b) => {
- const ao = a.item.order ?? Number.MAX_SAFE_INTEGER;
- const bo = b.item.order ?? Number.MAX_SAFE_INTEGER;
- return ao - bo || a.i - b.i;
- })
- .map(({ item }) => item);
-}
-
// Normalize a raw siteUrl into a trimmed absolute http(s) URL with no trailing
// slash, or undefined. Mirrors parseSiteUrl in site.ts.
export function parseHomepageUrl(input: unknown): string | undefined {
@@ -110,7 +64,6 @@ export function parseHomepageConfig(raw: unknown): HomepageConfig {
socialLinks: Array.isArray(r.socialLinks)
? parseSocialLinks(r.socialLinks)
: undefined,
- nav: parseHomepageNav(r.nav),
siteUrl: parseHomepageUrl(r.siteUrl),
cloudflareProject:
typeof r.cloudflareProject === "string" && r.cloudflareProject.trim()
@@ -159,7 +112,6 @@ export async function writeHomepageConfig(
headerTitle: config.headerTitle,
homeTagline: config.homeTagline,
...(socialLinks !== undefined ? { socialLinks } : {}),
- nav: parseHomepageNav(config.nav),
...(parseHomepageUrl(config.siteUrl)
? { siteUrl: parseHomepageUrl(config.siteUrl) }
: {}),
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -0,0 +1,51 @@
+// Pure (fs-free, React-free) presentation helpers + control types shared by the
+// hub landing's cross-site chart (common/components/charts/CrossSiteChart) and
+// its site cards (homepage SiteGrid). Color is keyed on a series' index so the
+// chart and a card's legend swatch always agree.
+
+import type { MetricKey } from "./homepageSummary";
+
+// Chart control axes (each a small segmented button group on the landing).
+export type Breakdown = "site" | "channel";
+export type Bucket = "week" | "month" | "cumulative";
+export type Range = "90d" | "12mo" | "all";
+export type Metric = MetricKey; // "transcribed" | "downloaded"
+export type Display = "share" | "counts";
+
+// Stable series color. The first five use the globals.css palette
+// (--chart-1..5); beyond that we spread hues by the golden angle so many
+// channels / >5 sites stay visually distinct (and stable across renders).
+export function seriesColor(index: number): string {
+ if (index < 5) return `var(--chart-${index + 1})`;
+ const hue = Math.round((index * 137.508) % 360);
+ return `hsl(${hue} 64% 55%)`;
+}
+
+// Muted band for the grouped "Other" channels bucket.
+export const OTHER_COLOR = "var(--muted-foreground)";
+
+const MONTHS = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+];
+
+// "2026-06" -> "Jun 2026"
+export function monthLabel(ym: string): string {
+ const [y, m] = ym.split("-").map(Number);
+ return `${MONTHS[(m ?? 1) - 1] ?? ""} ${y}`;
+}
+
+// "2026-06-22" (week start) -> "Jun 22"
+export function weekLabel(iso: string): string {
+ const [, m, d] = iso.split("-").map(Number);
+ return `${MONTHS[(m ?? 1) - 1] ?? ""} ${d ?? ""}`;
+}
+
+// "YYYYMMDD" -> "Jun 28, 2026"
+export function dayLabel(yyyymmdd: string): string {
+ if (yyyymmdd.length < 8) return yyyymmdd;
+ const y = yyyymmdd.slice(0, 4);
+ const m = Number(yyyymmdd.slice(4, 6));
+ const d = Number(yyyymmdd.slice(6, 8));
+ return `${MONTHS[m - 1] ?? ""} ${d}, ${y}`;
+}
diff --git a/common/lib/homepageConstants.ts b/common/lib/homepageConstants.ts
@@ -1,23 +0,0 @@
-// Pure (fs-free) homepage constants, types, and validators — safe to import from
-// CLIENT components. The filesystem-backed config/page IO lives in homepage.ts
-// and homepagePages.ts (server only); importing those into a client bundle would
-// drag `node:fs` in and break the Turbopack client build.
-
-// The canonical home page slug: its body renders at "/", never at "/<slug>".
-export const HOMEPAGE_INDEX_SLUG = "index";
-
-// Page/nav slug grammar: a lowercase slug, safe as a file and URL segment.
-// Mirrors SITE_ID_RE in site.ts (kept inline here so this module pulls no fs).
-const HOMEPAGE_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
-
-export function isValidHomepageSlug(slug: unknown): slug is string {
- return typeof slug === "string" && HOMEPAGE_SLUG_RE.test(slug);
-}
-
-// One nav/page entry. The Markdown body is stored separately (homepagePages.ts);
-// the nav array is the single source of truth for page ORDER and link LABEL.
-export type HomepageNavItem = {
- slug: string;
- label: string;
- order?: number;
-};
diff --git a/common/lib/homepagePages.ts b/common/lib/homepagePages.ts
@@ -1,124 +0,0 @@
-import fs from "node:fs";
-import path from "node:path";
-import { getPaths, type Paths } from "./paths";
-import { HOMEPAGE_INDEX_SLUG, isValidHomepageSlug } from "./homepage";
-
-// On-disk store for the hub's Markdown page bodies, one file per page at
-// homepagePagesDir/<slug>.md. Page ORDER and link LABEL live in the homepage
-// config's `nav` array (see homepage.ts) — this module owns only the prose, so
-// the editor can CRUD page content and render it with markdown-to-jsx at build.
-// Bodies are plain Markdown (markdown-to-jsx renders Markdown + HTML-like tags;
-// it does NOT compile real MDX imports), which is exactly what we want for the
-// SSG hub.
-
-const PAGE_EXT = ".md";
-
-export type HomepagePage = {
- slug: string;
- body: string;
-};
-
-// Built-in fallback doc. `output: "export"` rejects the `[slug]` dynamic route
-// when generateStaticParams() is empty, which happens for a hub with no docs
-// pages OR one with only a home (`index`) page. To keep the route valid we
-// always build at least this one doc; it's superseded the moment any real non-
-// index page is authored. See homepageDocSlugs / getHomepageDoc below.
-export const DEFAULT_HOMEPAGE_DOC: HomepagePage = {
- slug: "about",
- body:
- "# About\n\nThis is the Archilyzer hub. Edit this page or add new ones in the " +
- "editor under **Manage → Homepage**.\n",
-};
-
-function pageFile(paths: Paths, slug: string): string {
- return path.join(paths.homepagePagesDir, `${slug}${PAGE_EXT}`);
-}
-
-// All page slugs that have a body file on disk, sorted for stable iteration.
-// Invalid filenames (not <validSlug>.md) are ignored.
-export function listHomepagePageSlugs(paths: Paths = getPaths()): string[] {
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(paths.homepagePagesDir, { withFileTypes: true });
- } catch {
- return [];
- }
- const slugs: string[] = [];
- for (const e of entries) {
- if (!e.isFile() || !e.name.endsWith(PAGE_EXT)) continue;
- const slug = e.name.slice(0, -PAGE_EXT.length);
- if (isValidHomepageSlug(slug)) slugs.push(slug);
- }
- return slugs.sort((a, b) => a.localeCompare(b));
-}
-
-export function getHomepagePage(
- slug: string,
- paths: Paths = getPaths(),
-): HomepagePage | null {
- if (!isValidHomepageSlug(slug)) return null;
- try {
- const body = fs.readFileSync(pageFile(paths, slug), "utf8");
- return { slug, body };
- } catch {
- return null;
- }
-}
-
-export function listHomepagePages(paths: Paths = getPaths()): HomepagePage[] {
- return listHomepagePageSlugs(paths)
- .map((slug) => getHomepagePage(slug, paths))
- .filter((p): p is HomepagePage => p !== null);
-}
-
-// Real, authored non-index doc slugs (the ones that get a /<slug> route).
-function realDocSlugs(paths: Paths): string[] {
- return listHomepagePageSlugs(paths).filter((s) => s !== HOMEPAGE_INDEX_SLUG);
-}
-
-// Doc slugs to route/build: the authored non-index pages, or the single built-in
-// fallback when there are none (so the static `[slug]` route always has ≥1
-// param). Use for generateStaticParams and the hub nav.
-export function homepageDocSlugs(paths: Paths = getPaths()): string[] {
- const real = realDocSlugs(paths);
- return real.length ? real : [DEFAULT_HOMEPAGE_DOC.slug];
-}
-
-// Resolve a doc for rendering: the authored file if present, else the built-in
-// fallback when its slug is requested and no real docs exist. Null otherwise.
-export function getHomepageDoc(
- slug: string,
- paths: Paths = getPaths(),
-): HomepagePage | null {
- const real = getHomepagePage(slug, paths);
- if (real) return real;
- if (slug === DEFAULT_HOMEPAGE_DOC.slug && realDocSlugs(paths).length === 0) {
- return DEFAULT_HOMEPAGE_DOC;
- }
- return null;
-}
-
-export async function writeHomepagePage(
- slug: string,
- body: string,
- paths: Paths = getPaths(),
-): Promise<void> {
- if (!isValidHomepageSlug(slug)) {
- throw new Error(`Invalid homepage page slug: ${String(slug)}`);
- }
- await fs.promises.mkdir(paths.homepagePagesDir, { recursive: true });
- const file = pageFile(paths, slug);
- const tmp = `${file}.tmp-${process.pid}`;
- await fs.promises.writeFile(tmp, body);
- await fs.promises.rename(tmp, file);
-}
-
-export async function deleteHomepagePage(
- slug: string,
- paths: Paths = getPaths(),
-): Promise<void> {
- if (!isValidHomepageSlug(slug)) {
- throw new Error(`Invalid homepage page slug: ${String(slug)}`);
- }
- await fs.promises.rm(pageFile(paths, slug), { force: true });
-}
diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts
@@ -0,0 +1,321 @@
+import type { Platform } from "./platform";
+import type { VideoStat } from "./stats";
+import type { Site } from "./site";
+
+// Pre-computed, lightweight cross-site summary for the hub (homepage) landing.
+// Built once at compose time (compose-homepage.ts) and embedded into the SSG
+// HTML, so the landing renders instantly without the browser fetching the
+// multi-MB whole-pool stats dataset.
+//
+// Scope: the chart "universe" is PUBLIC sites only (those with a siteUrl), and
+// every video is attributed to a single PRIMARY public site (the first, by
+// sorted id, exposing its channel) so the Site and Channel breakdowns partition
+// the same set and combined totals stay honest. The KPI `totals` are instance-
+// wide (count pool-only / URL-less content too) — a deliberate scope difference.
+//
+// Two metrics are pre-binned at two granularities; Cumulative and Share (100%)
+// are derived client-side from these, so no extra precompute is needed.
+
+export const HOMEPAGE_SUMMARY_VERSION = 2;
+
+// channel slug -> ids of the content sites that expose it (same map
+// compose-homepage builds for the cross-site charts).
+export type ChannelSitesMap = Record<string, string[]>;
+
+export type MetricKey = "transcribed" | "downloaded";
+
+// Per-granularity counts. `buckets` are ascending and continuous (gaps are 0):
+// week buckets are Monday-anchored UTC dates ("YYYY-MM-DD"); month buckets are
+// "YYYY-MM". `total`/`bySite[id]`/`byChannel[slug]` are all aligned to `buckets`.
+export type BucketSeries = {
+ buckets: string[];
+ total: number[];
+ bySite: Record<string, number[]>;
+ byChannel: Record<string, number[]>;
+};
+
+export type MetricSeries = { week: BucketSeries; month: BucketSeries };
+
+export type SiteMetricStat = {
+ total: number;
+ thisMonth: number;
+ last12: number[]; // last 12 month buckets (zero-padded left), for the sparkline
+};
+
+export type HomepageSummarySite = {
+ siteId: string;
+ siteTitle: string;
+ siteDescription: string;
+ siteUrl: string; // public sites only — always present
+ transcribed: SiteMetricStat;
+ downloaded: SiteMetricStat;
+};
+
+export type HomepageChannelMeta = { slug: string; name: string };
+
+export type HomepageRecentItem = {
+ slug: string; // `${channelSlug}/${id}` — the export deep-link `?v=` value
+ title: string;
+ channel: string; // display name
+ platform: Platform;
+ siteId: string;
+ siteTitle: string;
+ siteUrl: string;
+ transcribedDate: string; // YYYYMMDD
+};
+
+export type HomepageSummary = {
+ version: number;
+ generatedAt: string; // ISO timestamp
+ // Instance-wide headline numbers (count everything, not just public sites).
+ totals: {
+ transcripts: number;
+ downloads: number;
+ sites: number; // public site count
+ channels: number;
+ hoursArchived: number; // sum of transcribed-video durations, in hours
+ transcribedThisMonth: number;
+ downloadedThisMonth: number;
+ };
+ // Public-universe channels (for the Channel-breakdown legend labels).
+ channels: HomepageChannelMeta[];
+ series: Record<MetricKey, MetricSeries>;
+ // Public sites, sorted by transcribed total desc.
+ sites: HomepageSummarySite[];
+ // Newest transcriptions (public universe), newest first. Built but not rendered
+ // on the current landing — kept so the feed can be re-enabled with no rework.
+ recent: HomepageRecentItem[];
+};
+
+const RECENT_LIMIT = 24;
+
+// ─── date bucketing ───
+
+function monthOf(date: string | null): string | null {
+ if (!date || date.length < 6) return null;
+ return `${date.slice(0, 4)}-${date.slice(4, 6)}`;
+}
+
+function monthRange(start: string, end: string): string[] {
+ const out: string[] = [];
+ let [y, m] = start.split("-").map(Number);
+ const [ey, em] = end.split("-").map(Number);
+ while (y < ey || (y === ey && m <= em)) {
+ out.push(`${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}`);
+ m += 1;
+ if (m > 12) {
+ m = 1;
+ y += 1;
+ }
+ }
+ return out;
+}
+
+function isoDate(dt: Date): string {
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}-${String(dt.getUTCDate()).padStart(2, "0")}`;
+}
+
+// "YYYYMMDD" -> the Monday-anchored UTC week-start "YYYY-MM-DD" (or null).
+function weekOf(date: string | null): string | null {
+ if (!date || date.length < 8) return null;
+ const y = +date.slice(0, 4);
+ const m = +date.slice(4, 6);
+ const d = +date.slice(6, 8);
+ if (!y || !m || !d) return null;
+ const dt = new Date(Date.UTC(y, m - 1, d));
+ const diff = (dt.getUTCDay() + 6) % 7; // days since Monday (Sun=0 → 6)
+ dt.setUTCDate(dt.getUTCDate() - diff);
+ return isoDate(dt);
+}
+
+function weekRange(start: string, end: string): string[] {
+ const out: string[] = [];
+ const dt = new Date(`${start}T00:00:00Z`);
+ const endDt = new Date(`${end}T00:00:00Z`);
+ while (dt.getTime() <= endDt.getTime()) {
+ out.push(isoDate(dt));
+ dt.setUTCDate(dt.getUTCDate() + 7);
+ }
+ return out;
+}
+
+// ─── helpers ───
+
+type Attributed = { date: string; channelSlug: string; siteId: string };
+
+// Bin a metric's attributed videos at one granularity into a BucketSeries.
+function bucketize(
+ items: Attributed[],
+ keyOf: (date: string) => string | null,
+ rangeOf: (min: string, max: string) => string[],
+ nowKey: string,
+): BucketSeries {
+ let min: string | null = null;
+ for (const it of items) {
+ const k = keyOf(it.date);
+ if (k && (min === null || k < min)) min = k;
+ }
+ const buckets = min ? rangeOf(min < nowKey ? min : nowKey, nowKey) : [];
+ const index = new Map(buckets.map((b, i) => [b, i]));
+ const total = new Array(buckets.length).fill(0);
+ const bySite: Record<string, number[]> = {};
+ const byChannel: Record<string, number[]> = {};
+ for (const it of items) {
+ const k = keyOf(it.date);
+ const i = k != null ? index.get(k) : undefined;
+ if (i == null) continue;
+ total[i] += 1;
+ (bySite[it.siteId] ??= new Array(buckets.length).fill(0))[i] += 1;
+ (byChannel[it.channelSlug] ??= new Array(buckets.length).fill(0))[i] += 1;
+ }
+ return { buckets, total, bySite, byChannel };
+}
+
+function siteStatFrom(
+ month: BucketSeries,
+ siteId: string,
+ nowMonth: string,
+): SiteMetricStat {
+ const arr = month.bySite[siteId] ?? [];
+ const total = arr.reduce((a, b) => a + b, 0);
+ const idx = month.buckets.indexOf(nowMonth);
+ const thisMonth = idx >= 0 ? (arr[idx] ?? 0) : 0;
+ const last12 = arr.slice(Math.max(0, arr.length - 12));
+ while (last12.length < 12) last12.unshift(0);
+ return { total, thisMonth, last12 };
+}
+
+export function buildHomepageSummary(
+ stats: readonly VideoStat[],
+ channelSites: ChannelSitesMap,
+ sites: readonly Site[],
+ now: Date,
+): HomepageSummary {
+ const nowMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
+ const nowWeek = weekOf(
+ `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}`,
+ )!;
+
+ // Public sites only (need a link target + a stable place on the chart).
+ const publicSites = sites.filter((s): s is Site & { siteUrl: string } => !!s.siteUrl);
+ const siteById = new Map(publicSites.map((s) => [s.siteId, s]));
+
+ // channel slug -> primary public site (first by sorted id). Channels with no
+ // public site are out of the chart universe entirely.
+ const primarySiteOf = new Map<string, string>();
+ for (const slug of Object.keys(channelSites)) {
+ const primary = [...channelSites[slug]]
+ .filter((id) => siteById.has(id))
+ .sort()[0];
+ if (primary) primarySiteOf.set(slug, primary);
+ }
+
+ // Collect attributed (public-universe) videos per metric, plus channel names.
+ const channelName = new Map<string, string>();
+ const transcribedItems: Attributed[] = [];
+ const downloadedItems: Attributed[] = [];
+ // Instance-wide KPI accumulators (count everything, not just public).
+ let transcripts = 0;
+ let downloads = 0;
+ let hoursSeconds = 0;
+ let transcribedThisMonth = 0;
+ let downloadedThisMonth = 0;
+ const channelSet = new Set<string>();
+
+ for (const s of stats) {
+ const hasTx = s.hasTranscript && !!s.transcribedDate;
+ if (hasTx) {
+ transcripts += 1;
+ channelSet.add(s.channelSlug);
+ hoursSeconds += s.duration > 0 ? s.duration : 0;
+ if (monthOf(s.transcribedDate) === nowMonth) transcribedThisMonth += 1;
+ }
+ if (s.downloadedDate) {
+ downloads += 1;
+ if (monthOf(s.downloadedDate) === nowMonth) downloadedThisMonth += 1;
+ }
+ const siteId = primarySiteOf.get(s.channelSlug);
+ if (!siteId) continue; // not on any public site
+ channelName.set(s.channelSlug, s.channel);
+ if (hasTx) {
+ transcribedItems.push({ date: s.transcribedDate as string, channelSlug: s.channelSlug, siteId });
+ }
+ if (s.downloadedDate) {
+ downloadedItems.push({ date: s.downloadedDate, channelSlug: s.channelSlug, siteId });
+ }
+ }
+
+ const metricSeries = (items: Attributed[]): MetricSeries => ({
+ week: bucketize(items, weekOf, weekRange, nowWeek),
+ month: bucketize(items, monthOf, monthRange, nowMonth),
+ });
+ const series: Record<MetricKey, MetricSeries> = {
+ transcribed: metricSeries(transcribedItems),
+ downloaded: metricSeries(downloadedItems),
+ };
+
+ const summarySites: HomepageSummarySite[] = publicSites
+ .map((s) => ({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ siteDescription: s.siteDescription,
+ siteUrl: s.siteUrl,
+ transcribed: siteStatFrom(series.transcribed.month, s.siteId, nowMonth),
+ downloaded: siteStatFrom(series.downloaded.month, s.siteId, nowMonth),
+ }))
+ // Keep a public site only if it has any activity in either metric.
+ .filter((s) => s.transcribed.total > 0 || s.downloaded.total > 0)
+ .sort(
+ (a, b) =>
+ b.transcribed.total - a.transcribed.total ||
+ a.siteTitle.localeCompare(b.siteTitle),
+ );
+
+ const channels: HomepageChannelMeta[] = [...channelName.entries()]
+ .map(([slug, name]) => ({ slug, name }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ // Recent feed (public universe), attributed to the primary site for its link.
+ const recent: HomepageRecentItem[] = stats
+ .filter((s): s is VideoStat & { transcribedDate: string } =>
+ Boolean(s.hasTranscript && s.transcribedDate && primarySiteOf.get(s.channelSlug)),
+ )
+ .sort(
+ (a, b) =>
+ b.transcribedDate.localeCompare(a.transcribedDate) ||
+ a.slug.localeCompare(b.slug),
+ )
+ .slice(0, RECENT_LIMIT)
+ .map((s) => {
+ const siteId = primarySiteOf.get(s.channelSlug)!;
+ const site = siteById.get(siteId)!;
+ return {
+ slug: s.slug,
+ title: s.title,
+ channel: s.channel,
+ platform: s.platform,
+ siteId,
+ siteTitle: site.siteTitle,
+ siteUrl: site.siteUrl,
+ transcribedDate: s.transcribedDate,
+ };
+ });
+
+ return {
+ version: HOMEPAGE_SUMMARY_VERSION,
+ generatedAt: now.toISOString(),
+ totals: {
+ transcripts,
+ downloads,
+ sites: summarySites.length,
+ channels: channelSet.size,
+ hoursArchived: Math.round(hoursSeconds / 3600),
+ transcribedThisMonth,
+ downloadedThisMonth,
+ },
+ channels,
+ series,
+ sites: summarySites,
+ recent,
+ };
+}
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -19,13 +19,12 @@ export type Paths = {
// single global channel pool; channel downloads are never duplicated per site.
sitesDir: string;
// The Archilyzer hub/homepage (the `homepage` SSG package): a single, instance-
- // level marketing/docs site, NOT a content site. Its config + Markdown pages +
- // chart dashboard live under sitesDir/_homepage. The leading underscore is
+ // level cross-site landing site, NOT a content site. Its config + chart
+ // dashboard live under sitesDir/_homepage. The leading underscore is
// intentionally not a valid siteId (SITE_ID_RE), so listSiteIds() ignores it.
- // See common/lib/homepage.ts and common/lib/homepagePages.ts.
+ // See common/lib/homepage.ts.
homepageDir: string;
homepageConfigFile: string;
- homepagePagesDir: string;
homepageChartTemplatesFile: string;
jobsDir: string;
// Scratch area for remote-worker transcription requests: uploaded audio and
@@ -115,7 +114,6 @@ export function getPaths(): Paths {
sitesDir,
homepageDir,
homepageConfigFile: path.join(homepageDir, "homepage.json"),
- homepagePagesDir: path.join(homepageDir, "pages"),
homepageChartTemplatesFile: path.join(homepageDir, "chart-templates.json"),
jobsDir: path.join(transcriptsDir, ".jobs"),
workerScratchDir: path.join(transcriptsDir, ".worker-scratch"),
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Hub homepage redesigned into a cross-site landing; the homepage page-creator is removed.** The hub's home page is now a single mobile-first cross-site landing (headline KPIs and one stacked activity chart with Metric [Transcribed/Downloaded] · Breakdown [By site/By channel] · Bucket [Week/Month/Cumulative] · Range [90d/12mo/All] · Display [Share/Counts] controls, plus a metric-aware site-links grid with sparklines and a "#1 this month" badge), built from a small `homepage-summary.json` pre-computed by `compose-homepage`. The separate `/stats` dashboard route folds into it. Consequently the hub's **Markdown-pages subsystem is dropped**: **Manage → Homepage** now edits only branding (the Pages list, New-page, and the page editor are gone), and the homepage config no longer carries a `nav`. The page server actions (`saveHomepagePageAction`/`deleteHomepagePageAction`), `editor/app/homepage/pages/*`, `PageEditor.tsx`, `common/lib/{homepagePages,homepageConstants}.ts`, and `paths.homepagePagesDir` are removed. See `editor/app/homepage/{page.tsx,actions.ts}`, `common/bin/compose-homepage.ts`, `common/lib/{homepageSummary,homepageChart}.ts`, and the `homepage/` package. (Re-addable later if needed.)
- **One-click Retry for failed jobs (plus "Retry all failed").** A failed job that carries a replay descriptor (any bookmarkable kind — sync, download-missing, transcribe-all, retry-bucket, …) now shows a **Retry** button on the Jobs history table, and the page header gains a **Retry all failed** button whenever at least one such job is listed. Retry re-runs the job from its stored spec exactly like a bookmark re-run (so bucket jobs re-derive from the channel's *current* state), and the re-run **jumps ahead of other queued work** (it's promoted to the front of its queue, reusing the new reorder machinery) so a fix-and-retry runs next rather than at the back of the line. The spec is resolved from the live registry or, for an evicted/archived job, from its on-disk `<id>.meta.json` sidecar — so even a failure the 100-job cap has dropped is still retryable. Kinds with no replay descriptor (e.g. `import-one`) intentionally offer no Retry. See `editor/app/jobs/actions.ts` (`retryJobAction` / `retryAllFailedAction`), the new `RetryJobButton` / `RetryAllFailedButton`, and `editor/e2e/jobs-retry.spec.ts`.
- **Reorder and promote queued jobs from Active Jobs.** A queued job's row now carries **Promote / ↑ / ↓** controls (mirroring the auto-queue policy editor's move buttons) to change its order within its queue — Promote sends it to the front so it runs next, ↑/↓ nudge it one slot. Only actionable moves render (the first-queued job shows no up/promote, the last no down), and a running job is never displaced. See `editor/app/jobs/components/ReorderJobButtons.tsx`, the `reorderJobAction` / `promoteJobAction` server actions, and `editor/e2e/jobs-reorder.spec.ts`.
- **Job-queue internals unified onto one scheduler + one concurrency primitive (foundational refactor; no behavior change beyond the two features above).** The "foreground preempts background" priority was previously implemented twice (once for job queue ordering, once for worker-slot waiters); both now share a single `compareTier` comparator in a new `common/jobs/scheduler.ts` (priority tiers urgent/foreground/background, per-queue concurrency, and the reorder/promote operations the UI uses), which the registry delegates its queue ordering to. The auto-transcribe/-download runner's hand-written fill-to-capacity loop **and** the whisper batch's `Promise.all` are both replaced by one audited `runPool()` primitive (`common/jobs/concurrentRunner.ts`) that encodes the no-event-loop-spin wait once — structurally eliminating the "Drain all hangs" bug class rather than patching each loop. Per-kind metadata (label, drainability, bookmarkability) is consolidated into one `common/jobs/jobKinds.ts` table, and the bookmark/replay dispatch is now a data-driven lookup, so adding a job kind touches ~1 file instead of ~5. Covered by new unit tests (`scheduler`, `concurrentRunner`, `jobKinds`) and the existing queue/drain e2e suites.
diff --git a/editor/app/homepage/PageEditor.tsx b/editor/app/homepage/PageEditor.tsx
@@ -1,157 +0,0 @@
-"use client";
-
-import { useActionState, useState } from "react";
-import { useRouter } from "next/navigation";
-import Markdown from "markdown-to-jsx";
-import {
- HOMEPAGE_INDEX_SLUG,
- type HomepageNavItem,
-} from "yt-dlp-transcript-common/lib/homepageConstants";
-import {
- saveHomepagePageAction,
- deleteHomepagePageAction,
- type PageSaveResult,
-} from "./actions";
-
-const input =
- "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-1.5 text-sm";
-const label = "flex flex-col gap-1 text-sm font-medium";
-
-// Create (no `existing`) or edit (with `existing`) one Markdown page. The slug is
-// immutable once created; the body is edited with a live markdown-to-jsx preview
-// so authors see what the static hub will render.
-export function PageEditor({
- existing,
- nav,
-}: {
- existing?: { slug: string; body: string };
- nav: HomepageNavItem[];
-}) {
- const router = useRouter();
- const navItem = existing
- ? nav.find((n) => n.slug === existing.slug)
- : undefined;
- const [body, setBody] = useState(existing?.body ?? "");
- const [state, formAction, pending] = useActionState<
- PageSaveResult | undefined,
- FormData
- >(async (prev, formData) => {
- const result = await saveHomepagePageAction(prev, formData);
- if (result.ok && !existing) router.push(`/homepage/pages/${result.slug}`);
- return result;
- }, undefined);
-
- const onDelete = async () => {
- if (!existing) return;
- if (!confirm(`Delete page "${existing.slug}"?`)) return;
- const result = await deleteHomepagePageAction(existing.slug);
- if (result.ok) router.push("/homepage");
- else alert(result.error);
- };
-
- return (
- <form action={formAction} className="flex flex-col gap-3">
- <div className="grid gap-3 sm:grid-cols-3 max-w-2xl">
- <label className={label}>
- Slug
- {existing ? (
- <>
- <input
- type="hidden"
- name="slug"
- value={existing.slug}
- readOnly
- />
- <span className={`${input} text-zinc-500`}>{existing.slug}</span>
- </>
- ) : (
- <input
- className={input}
- name="slug"
- placeholder="getting-started"
- required
- />
- )}
- {!existing && (
- <span className="text-xs font-normal text-zinc-500">
- Use <code>{HOMEPAGE_INDEX_SLUG}</code> for the home page body.
- </span>
- )}
- </label>
- <label className={label}>
- Nav label
- <input
- className={input}
- name="label"
- defaultValue={navItem?.label ?? ""}
- placeholder="Getting started"
- />
- </label>
- <label className={label}>
- Order
- <input
- className={input}
- name="order"
- inputMode="numeric"
- defaultValue={navItem?.order ?? ""}
- placeholder="(optional)"
- />
- </label>
- </div>
-
- <div className="grid gap-3 lg:grid-cols-2">
- <label className={label}>
- Markdown
- <textarea
- name="body"
- value={body}
- onChange={(e) => setBody(e.target.value)}
- rows={20}
- className={`${input} font-mono`}
- />
- </label>
- <div className="flex flex-col gap-1 text-sm font-medium">
- Preview
- <div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2 overflow-auto min-h-[10rem]">
- {body.trim() ? (
- <Markdown options={{ forceBlock: true }}>{body}</Markdown>
- ) : (
- <span className="text-zinc-400 font-normal">
- Nothing to preview yet.
- </span>
- )}
- </div>
- </div>
- </div>
-
- <div className="flex items-center gap-3">
- <button
- type="submit"
- disabled={pending}
- className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
- >
- {pending ? "Saving…" : existing ? "Save page" : "Create page"}
- </button>
- {existing && (
- <button
- type="button"
- onClick={onDelete}
- className="rounded-md border border-red-300 dark:border-red-800 px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950"
- >
- Delete
- </button>
- )}
- {state && !state.ok && (
- <span className="text-sm text-red-600 dark:text-red-400">
- {state.error}
- </span>
- )}
- {state && state.ok && (
- <span className="text-sm text-green-600 dark:text-green-400">
- Saved.
- </span>
- )}
- </div>
- </form>
- );
-}
diff --git a/editor/app/homepage/actions.ts b/editor/app/homepage/actions.ts
@@ -6,19 +6,13 @@ import {
getHomepageConfig,
writeHomepageConfig,
parseHomepageUrl,
- isValidHomepageSlug,
type HomepageConfig,
- type HomepageNavItem,
} from "yt-dlp-transcript-common/lib/homepage";
-import {
- writeHomepagePage,
- deleteHomepagePage,
-} from "yt-dlp-transcript-common/lib/homepagePages";
export type SaveResult = { ok: true } | { ok: false; error: string };
-// Branding/config. Page nav is owned by the page actions below, so this preserves
-// the existing nav and social-link override and only writes branding fields.
+// Branding/config for the hub landing site. The hub is a single cross-site
+// landing page (no authored Markdown pages), so this is the only homepage action.
export async function saveHomepageConfigAction(
_prev: SaveResult | undefined,
formData: FormData,
@@ -56,65 +50,3 @@ export async function saveHomepageConfigAction(
revalidatePath("/homepage");
return { ok: true };
}
-
-// Create/update a Markdown page: writes the body file AND upserts the nav entry
-// (label/order) so the page shows up in the hub's nav. Returns the slug so the
-// client can redirect on create.
-export type PageSaveResult =
- | { ok: true; slug: string }
- | { ok: false; error: string };
-
-export async function saveHomepagePageAction(
- _prev: PageSaveResult | undefined,
- formData: FormData,
-): Promise<PageSaveResult> {
- const paths = getPaths();
- const slug = String(formData.get("slug") ?? "").trim();
- if (!isValidHomepageSlug(slug)) {
- return {
- ok: false,
- error: "Slug must be lowercase letters, numbers, and hyphens.",
- };
- }
- const label = String(formData.get("label") ?? "").trim() || slug;
- const orderRaw = String(formData.get("order") ?? "").trim();
- const order = orderRaw ? Number(orderRaw) : undefined;
- if (orderRaw && !Number.isFinite(order)) {
- return { ok: false, error: "Order must be a number, or left blank." };
- }
- const body = String(formData.get("body") ?? "");
- try {
- await writeHomepagePage(slug, body, paths);
- const current = getHomepageConfig(paths);
- const nav: HomepageNavItem[] = current.nav.filter((n) => n.slug !== slug);
- nav.push({
- slug,
- label,
- ...(order != null && Number.isFinite(order) ? { order } : {}),
- });
- await writeHomepageConfig({ ...current, nav }, paths);
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
- }
- revalidatePath("/homepage");
- revalidatePath(`/homepage/pages/${slug}`);
- return { ok: true, slug };
-}
-
-export async function deleteHomepagePageAction(
- slug: string,
-): Promise<SaveResult> {
- const paths = getPaths();
- try {
- await deleteHomepagePage(slug, paths);
- const current = getHomepageConfig(paths);
- await writeHomepageConfig(
- { ...current, nav: current.nav.filter((n) => n.slug !== slug) },
- paths,
- );
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
- }
- revalidatePath("/homepage");
- return { ok: true };
-}
diff --git a/editor/app/homepage/page.tsx b/editor/app/homepage/page.tsx
@@ -1,8 +1,6 @@
import type { Metadata } from "next";
-import Link from "next/link";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
-import { listHomepagePageSlugs } from "yt-dlp-transcript-common/lib/homepagePages";
import { HomepageConfigForm } from "./HomepageConfigForm";
export const dynamic = "force-dynamic";
@@ -11,17 +9,15 @@ export const metadata: Metadata = { title: "Homepage" };
export default function HomepageAdminPage() {
const paths = getPaths();
const config = getHomepageConfig(paths);
- const slugs = listHomepagePageSlugs(paths);
- const navBySlug = new Map(config.nav.map((n) => [n.slug, n]));
return (
<div className="flex flex-col gap-8">
<div>
<h1 className="text-2xl font-semibold">Homepage (hub)</h1>
<p className="mt-1 text-sm text-zinc-500">
- The Archilyzer marketing/docs site, built by the{" "}
- <code>homepage</code> package. Branding and Markdown pages are edited
- here; its cross-site stats charts build from the whole channel pool.
+ The Archilyzer hub, built by the <code>homepage</code> package. Its
+ home page is a cross-site stats landing built from the whole channel
+ pool; only its branding is edited here.
</p>
</div>
@@ -29,40 +25,6 @@ export default function HomepageAdminPage() {
<h2 className="text-lg font-semibold">Config</h2>
<HomepageConfigForm config={config} />
</section>
-
- <section className="flex flex-col gap-3">
- <div className="flex items-center justify-between">
- <h2 className="text-lg font-semibold">Pages</h2>
- <Link
- href="/homepage/pages/new"
- className="rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800"
- >
- New page
- </Link>
- </div>
- {slugs.length === 0 ? (
- <p className="text-sm text-zinc-500">
- No pages yet. Create one — use the slug <code>index</code> for the
- home page body.
- </p>
- ) : (
- <ul className="flex flex-col gap-1 list-none">
- {slugs.map((slug) => (
- <li key={slug}>
- <Link
- href={`/homepage/pages/${slug}`}
- className="flex items-baseline gap-2 rounded-md border border-zinc-200 dark:border-zinc-800 px-3 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800"
- >
- <span className="font-mono">{slug}</span>
- <span className="text-zinc-500">
- {navBySlug.get(slug)?.label ?? ""}
- </span>
- </Link>
- </li>
- ))}
- </ul>
- )}
- </section>
</div>
);
}
diff --git a/editor/app/homepage/pages/[slug]/page.tsx b/editor/app/homepage/pages/[slug]/page.tsx
@@ -1,41 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
-import { getHomepagePage } from "yt-dlp-transcript-common/lib/homepagePages";
-import { PageEditor } from "../../PageEditor";
-
-export const dynamic = "force-dynamic";
-
-export async function generateMetadata({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}): Promise<Metadata> {
- const { slug } = await params;
- return { title: `Edit ${slug}` };
-}
-
-export default async function EditHomepagePage({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}) {
- const { slug } = await params;
- const paths = getPaths();
- const page = getHomepagePage(slug, paths);
- if (!page) notFound();
- const config = getHomepageConfig(paths);
- return (
- <div className="flex flex-col gap-4">
- <Link href="/homepage" className="text-sm text-zinc-500 hover:underline">
- ← Homepage
- </Link>
- <h1 className="text-2xl font-semibold">
- Edit <span className="font-mono">{slug}</span>
- </h1>
- <PageEditor existing={page} nav={config.nav} />
- </div>
- );
-}
diff --git a/editor/app/homepage/pages/new/page.tsx b/editor/app/homepage/pages/new/page.tsx
@@ -1,21 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
-import { PageEditor } from "../../PageEditor";
-
-export const dynamic = "force-dynamic";
-export const metadata: Metadata = { title: "New page" };
-
-export default function NewHomepagePage() {
- const config = getHomepageConfig(getPaths());
- return (
- <div className="flex flex-col gap-4">
- <Link href="/homepage" className="text-sm text-zinc-500 hover:underline">
- ← Homepage
- </Link>
- <h1 className="text-2xl font-semibold">New page</h1>
- <PageEditor nav={config.nav} />
- </div>
- );
-}
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -1,5 +1,43 @@
# Homepage Changelog
+## 2026-06-28
+
+- **The home page is now the cross-site landing.** The thin branding hero and the
+ separate `/stats` dashboard are replaced by a single, mobile-first landing built
+ from a small summary pre-computed at build (`compose-homepage` writes
+ `public/homepage-summary.json`, embedded into the static HTML — no multi-MB
+ client fetch). It leads with headline KPIs (transcripts, downloads, sites,
+ channels, hours archived), then one prominent activity chart driven by five
+ small segmented controls: **Metric** (Transcribed / Downloaded), **Breakdown**
+ (By site / By channel), **Bucket** (Week / Month / Cumulative), **Range** (90d /
+ 12mo / All), and **Display** (Share / Counts). It's always a stacked chart — Week
+ /Month are stacked bars, Cumulative a stacked area — so per-series composition
+ *and* the combined total read at once; **Share** (100% stacked, the default)
+ keeps small sites legible next to a dominant one, while the KPIs carry the
+ absolute totals. By-channel groups to the top-8 channels + an "Other" band.
+ Below the chart a responsive **site grid** links every *public* content site
+ (cards follow the active metric — total, "+N this month", inline-SVG sparkline —
+ sorted by this-month, with a **"#1 this month"** badge); in By-site the cards
+ double as the chart legend with Show/Hide toggles. The data universe is public
+ sites only (URL-less sites are hidden), each video attributed to one primary
+ site so combined totals stay honest. (A recent-additions feed is still built
+ into the summary but not rendered — kept for easy re-enable.) The `/stats` route
+ and the trailing "Stats" header link are gone.
+- **Distinctive "archive room" visual identity.** The landing moves off the
+ generic zinc/white look to a committed warm-ink dark theme: an atmospheric
+ background (overhead brass glow + film grain), a **Fraunces** serif for the
+ wordmark / headline numerals / section heads paired with Geist Mono instrument
+ labels, a single brass/gold signature accent (eyebrows, active controls, the
+ "#1 this month" badge), glass panels with hairline borders, enriched card
+ sparklines (area fill + endpoint dot), and one orchestrated staggered page-load
+ reveal. The hub is dark-committed (a class-based `dark` variant, not OS-driven).
+ Chart entry animation is disabled so the stacked bars/areas paint immediately.
+- **Hub Markdown pages dropped.** The `[slug]` doc routes, the `index`-page home
+ body, the `PageBody` renderer, and the doc nav are removed — the hub is now a
+ single page (re-addable later if needed). See `app/page.tsx`, the new hub
+ components under `app/components/`, `common/lib/{homepageSummary,homepageChart}.ts`,
+ and `common/components/charts/CrossSiteChart.tsx`.
+
## 2026-06-22
- New `homepage` package: the Archilyzer hub — a standalone Next.js static
diff --git a/homepage/app/[slug]/page.tsx b/homepage/app/[slug]/page.tsx
@@ -1,49 +0,0 @@
-import type { Metadata } from "next";
-import { notFound } from "next/navigation";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- getHomepageDoc,
- homepageDocSlugs,
-} from "yt-dlp-transcript-common/lib/homepagePages";
-import { currentHomepage } from "../lib/homepage";
-import PageBody from "../components/PageBody";
-
-// Every Markdown page except "index" (which is the home route) gets a static
-// "/<slug>" route. With output: "export" the full set is enumerated here at
-// build; unknown slugs 404. homepageDocSlugs() always returns ≥1 slug (a built-in
-// fallback when no docs are authored) so the export build never sees an empty
-// dynamic route.
-export const dynamicParams = false;
-
-export function generateStaticParams(): { slug: string }[] {
- return homepageDocSlugs(getPaths()).map((slug) => ({ slug }));
-}
-
-function labelFor(slug: string): string {
- const item = currentHomepage().nav.find((n) => n.slug === slug);
- return item?.label ?? slug;
-}
-
-export async function generateMetadata({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}): Promise<Metadata> {
- const { slug } = await params;
- return { title: labelFor(slug) };
-}
-
-export default async function HomepageDocPage({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}) {
- const { slug } = await params;
- const page = getHomepageDoc(slug, getPaths());
- if (!page) notFound();
- return (
- <div className="flex flex-col gap-2">
- <PageBody body={page.body} />
- </div>
- );
-}
diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx
@@ -9,11 +9,13 @@ export default function Footer() {
const config = currentHomepage();
const socialLinks = resolveHomepageSocialLinks(config, getSettings());
return (
- <footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
- <div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between gap-4 text-sm text-zinc-500">
- <span>{config.siteTitle}</span>
+ <footer className="mt-16 border-t border-[var(--border)]">
+ <div className="max-w-6xl mx-auto px-5 sm:px-6 py-6 flex items-center justify-between gap-4">
+ <span className="font-mono text-[0.6875rem] uppercase tracking-[0.22em] text-[var(--faint)]">
+ {config.siteTitle}
+ </span>
{socialLinks.length > 0 && (
- <ul className="flex items-center gap-3 list-none">
+ <ul className="flex items-center gap-4 list-none">
{socialLinks.map((link, i) => (
<li key={`${link.url}-${i}`}>
<a
@@ -22,7 +24,7 @@ export default function Footer() {
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
- className="inline-block w-5 h-5 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 [&_svg]:w-full [&_svg]:h-full"
+ className="inline-block w-5 h-5 text-[var(--faint)] hover:text-[var(--accent)] transition-colors [&_svg]:w-full [&_svg]:h-full"
dangerouslySetInnerHTML={{ __html: link.svg }}
/>
</li>
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -1,50 +1,22 @@
import Link from "next/link";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { homepageDocSlugs } from "yt-dlp-transcript-common/lib/homepagePages";
import { currentHomepage } from "../lib/homepage";
-function titleCase(slug: string): string {
- return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
-}
-
-// Top nav for the hub. The header title links home; doc links come from the
-// built doc routes (homepageDocSlugs — the same set the [slug] route builds,
-// including the built-in fallback when nothing is authored), labeled via the
-// config nav, with a trailing always-on "Stats" link to the cross-site board.
+// Top nav for the hub. The hub is a single cross-site landing, so the header is
+// just the branding mark linking home plus a quiet mono descriptor.
export default function Header() {
const config = currentHomepage();
- const labelFor = new Map(config.nav.map((n) => [n.slug, n.label]));
- const links = homepageDocSlugs(getPaths()).map((slug) => ({
- slug,
- label: labelFor.get(slug) ?? titleCase(slug),
- }));
return (
- <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 gap-3">
- <div className="flex items-center gap-5 min-w-0">
- <Link href="/" className="font-semibold tracking-tight shrink-0">
+ <header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[rgba(12,10,8,0.72)] backdrop-blur-md">
+ <div className="max-w-6xl mx-auto px-5 sm:px-6 h-14 flex items-center justify-between gap-3">
+ <Link href="/" className="group flex items-center gap-2.5">
+ <span className="h-2.5 w-2.5 rotate-45 rounded-[2px] bg-[var(--accent)] shadow-[0_0_14px_var(--accent)] transition group-hover:bg-[var(--accent-strong)]" />
+ <span className="font-display text-[1.35rem] leading-none font-semibold tracking-tight text-[var(--foreground)]">
{config.headerTitle}
- </Link>
- <nav className="flex items-center gap-4 text-sm font-medium">
- {links.map((n) => (
- <Link
- key={n.slug}
- href={`/${n.slug}`}
- className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400"
- >
- {n.label}
- </Link>
- ))}
- </nav>
- </div>
- <nav className="text-sm shrink-0">
- <Link
- href="/stats"
- className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- Stats
- </Link>
- </nav>
+ </span>
+ </Link>
+ <span className="hidden sm:block font-mono text-[0.6875rem] uppercase tracking-[0.22em] text-[var(--faint)]">
+ Cross-site transcript archive
+ </span>
</div>
</header>
);
diff --git a/homepage/app/components/HomeLanding.tsx b/homepage/app/components/HomeLanding.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSummary";
+import type {
+ Breakdown,
+ Bucket,
+ Range,
+ Metric,
+ Display,
+} from "yt-dlp-transcript-common/lib/homepageChart";
+import { CrossSiteChart } from "yt-dlp-transcript-common/components/charts/CrossSiteChart";
+import { KpiHeader } from "./KpiHeader";
+import { SiteGrid } from "./SiteGrid";
+
+// A compact segmented control — the "instrument panel" driving the single
+// landing chart. Touch-friendly and wraps on small screens.
+function Segmented<T extends string>({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: T;
+ options: { value: T; label: string }[];
+ onChange: (v: T) => void;
+}) {
+ return (
+ <div
+ role="group"
+ aria-label={label}
+ className="inline-flex items-center rounded-full border border-[var(--border)] bg-[var(--panel)] p-0.5 backdrop-blur-sm"
+ >
+ {options.map((o) => {
+ const active = o.value === value;
+ return (
+ <button
+ key={o.value}
+ type="button"
+ aria-pressed={active}
+ onClick={() => onChange(o.value)}
+ className={
+ "rounded-full px-3 py-1.5 text-[0.8125rem] font-medium transition-all duration-150 " +
+ (active
+ ? "bg-[var(--accent-soft)] text-[var(--accent-strong)] shadow-[inset_0_0_0_1px_var(--accent-soft)]"
+ : "text-[var(--muted-foreground)] hover:text-[var(--foreground)]")
+ }
+ >
+ {o.label}
+ </button>
+ );
+ })}
+ </div>
+ );
+}
+
+function Eyebrow({ index, children }: { index: string; children: string }) {
+ return (
+ <div className="flex items-center gap-3">
+ <span className="font-mono text-[0.6875rem] tracking-[0.28em] text-[var(--accent)]">
+ {index}
+ </span>
+ <span className="font-mono text-[0.6875rem] uppercase tracking-[0.28em] text-[var(--faint)]">
+ {children}
+ </span>
+ </div>
+ );
+}
+
+export function HomeLanding({ summary }: { summary: HomepageSummary }) {
+ const [breakdown, setBreakdown] = useState<Breakdown>("site");
+ const [bucket, setBucket] = useState<Bucket>("week");
+ const [range, setRange] = useState<Range>("90d");
+ const [metric, setMetric] = useState<Metric>("transcribed");
+ const [display, setDisplay] = useState<Display>("share");
+ const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
+
+ const toggleSite = useCallback((siteId: string) => {
+ setHidden((prev) => {
+ const next = new Set(prev);
+ if (next.has(siteId)) next.delete(siteId);
+ else next.add(siteId);
+ return next;
+ });
+ }, []);
+
+ const metricNoun = metric === "downloaded" ? "Downloads" : "Transcriptions";
+
+ return (
+ <div className="flex flex-col gap-12 sm:gap-16">
+ <div className="reveal" style={{ "--d": "120ms" } as React.CSSProperties}>
+ <KpiHeader summary={summary} />
+ </div>
+
+ <section
+ className="reveal flex flex-col gap-5"
+ style={{ "--d": "200ms" } as React.CSSProperties}
+ >
+ <div className="flex flex-col gap-2">
+ <Eyebrow index="01">Activity</Eyebrow>
+ <h2 className="font-display text-3xl sm:text-4xl font-semibold tracking-[-0.01em] text-[var(--foreground)]">
+ {metricNoun} over time
+ </h2>
+ </div>
+
+ <div className="flex flex-wrap items-center gap-2.5">
+ <Segmented
+ label="Metric"
+ value={metric}
+ onChange={setMetric}
+ options={[
+ { value: "transcribed", label: "Transcribed" },
+ { value: "downloaded", label: "Downloaded" },
+ ]}
+ />
+ <Segmented
+ label="Breakdown"
+ value={breakdown}
+ onChange={setBreakdown}
+ options={[
+ { value: "site", label: "By site" },
+ { value: "channel", label: "By channel" },
+ ]}
+ />
+ <Segmented
+ label="Bucket"
+ value={bucket}
+ onChange={setBucket}
+ options={[
+ { value: "week", label: "Week" },
+ { value: "month", label: "Month" },
+ { value: "cumulative", label: "Cumulative" },
+ ]}
+ />
+ <Segmented
+ label="Range"
+ value={range}
+ onChange={setRange}
+ options={[
+ { value: "90d", label: "90d" },
+ { value: "12mo", label: "12mo" },
+ { value: "all", label: "All" },
+ ]}
+ />
+ <Segmented
+ label="Display"
+ value={display}
+ onChange={setDisplay}
+ options={[
+ { value: "share", label: "Share" },
+ { value: "counts", label: "Counts" },
+ ]}
+ />
+ </div>
+
+ <div className="relative overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-3 pt-5 backdrop-blur-sm sm:p-5">
+ <div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-[var(--accent-soft)] to-transparent opacity-40" />
+ <CrossSiteChart
+ summary={summary}
+ breakdown={breakdown}
+ bucket={bucket}
+ range={range}
+ metric={metric}
+ display={display}
+ hidden={hidden}
+ />
+ </div>
+ {breakdown === "site" && summary.sites.length > 0 && (
+ <p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-[var(--faint)]">
+ Toggle a site card below to focus the chart
+ </p>
+ )}
+ </section>
+
+ <section
+ className="reveal flex flex-col gap-5"
+ style={{ "--d": "280ms" } as React.CSSProperties}
+ >
+ <div className="flex flex-col gap-2">
+ <Eyebrow index="02">Directory</Eyebrow>
+ <h2 className="font-display text-3xl sm:text-4xl font-semibold tracking-[-0.01em] text-[var(--foreground)]">
+ Sites
+ </h2>
+ </div>
+ <SiteGrid
+ sites={summary.sites}
+ metric={metric}
+ breakdown={breakdown}
+ hidden={hidden}
+ onToggle={toggleSite}
+ />
+ </section>
+ </div>
+ );
+}
diff --git a/homepage/app/components/KpiHeader.tsx b/homepage/app/components/KpiHeader.tsx
@@ -0,0 +1,60 @@
+import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSummary";
+
+function Stat({
+ value,
+ label,
+ delay,
+}: {
+ value: number;
+ label: string;
+ delay: number;
+}) {
+ return (
+ <div
+ className="reveal group relative flex flex-col gap-1 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--panel)] px-4 py-4 backdrop-blur-sm transition-colors hover:border-[var(--border-strong)]"
+ style={{ "--d": `${delay}ms` } as React.CSSProperties}
+ >
+ <span className="absolute left-0 top-0 h-full w-px bg-gradient-to-b from-[var(--accent)] to-transparent opacity-60" />
+ <span className="font-display text-[2rem] sm:text-[2.5rem] font-semibold leading-none tracking-[-0.01em] text-[var(--foreground)] tabular-nums">
+ {value.toLocaleString()}
+ </span>
+ <span className="font-mono text-[0.625rem] uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
+ {label}
+ </span>
+ </div>
+ );
+}
+
+// Headline KPI tiles for the hub landing. Pure presentational; numbers come
+// pre-computed in the summary, so this renders in the static HTML.
+export function KpiHeader({ summary }: { summary: HomepageSummary }) {
+ const t = summary.totals;
+ const updated = summary.generatedAt
+ ? new Date(summary.generatedAt).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })
+ : null;
+ const stats = [
+ { value: t.transcripts, label: "Transcripts" },
+ { value: t.downloads, label: "Downloads" },
+ { value: t.sites, label: "Sites" },
+ { value: t.channels, label: "Channels" },
+ { value: t.hoursArchived, label: "Hours archived" },
+ ];
+ return (
+ <section className="flex flex-col gap-3">
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
+ {stats.map((s, i) => (
+ <Stat key={s.label} value={s.value} label={s.label} delay={i * 70} />
+ ))}
+ </div>
+ {updated && (
+ <p className="font-mono text-[0.625rem] uppercase tracking-[0.18em] text-[var(--faint)]">
+ Index updated · {updated}
+ </p>
+ )}
+ </section>
+ );
+}
diff --git a/homepage/app/components/PageBody.tsx b/homepage/app/components/PageBody.tsx
@@ -1,85 +0,0 @@
-import Markdown from "markdown-to-jsx";
-
-// Renders a hub page's Markdown body to React, server-side (SSG). markdown-to-jsx
-// renders Markdown plus HTML-like tags via `overrides` — no MDX compile step, no
-// imports — which is all the hub's docs/info pages need. The override table here
-// is the docs-page analogue of common/components/Changelog.tsx (which keeps its
-// own changelog-specific h1/h2 anchor behavior).
-
-const OVERRIDES = {
- h1: {
- props: {
- className:
- "mt-8 mb-4 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 first:mt-0",
- },
- },
- h2: {
- props: {
- className:
- "mt-8 mb-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100",
- },
- },
- h3: {
- props: {
- className:
- "mt-6 mb-2 text-base font-semibold text-zinc-900 dark:text-zinc-100",
- },
- },
- p: {
- props: { className: "my-3 text-zinc-700 dark:text-zinc-300 leading-relaxed" },
- },
- ul: {
- props: {
- className:
- "list-disc pl-6 my-3 space-y-1 text-zinc-700 dark:text-zinc-300 marker:text-zinc-400",
- },
- },
- ol: {
- props: {
- className:
- "list-decimal pl-6 my-3 space-y-1 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-4 p-3 rounded-md bg-zinc-100 dark:bg-zinc-900 overflow-x-auto text-sm font-mono",
- },
- },
- a: {
- props: {
- className:
- "text-blue-600 dark:text-blue-400 underline decoration-zinc-400 hover:decoration-blue-600 dark:hover:decoration-blue-400",
- },
- },
- hr: {
- props: { className: "my-8 border-t border-zinc-200 dark:border-zinc-800" },
- },
- blockquote: {
- props: {
- className:
- "my-4 pl-4 border-l-2 border-zinc-300 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400",
- },
- },
-};
-
-export default function PageBody({ body }: { body: string }) {
- return (
- <article className="prose-none max-w-3xl">
- <Markdown options={{ overrides: OVERRIDES, forceBlock: true }}>
- {body}
- </Markdown>
- </article>
- );
-}
diff --git a/homepage/app/components/RecentAdditions.tsx b/homepage/app/components/RecentAdditions.tsx
@@ -0,0 +1,60 @@
+import type { HomepageRecentItem } from "yt-dlp-transcript-common/lib/homepageSummary";
+import { dayLabel } from "yt-dlp-transcript-common/lib/homepageChart";
+
+// Open the transcript on its destination site: the export apps open the modal
+// from the `v` slug param (see common/components/PlayerProvider). siteUrl carries
+// no trailing slash, so `${siteUrl}/?v=<slug>` targets the site root.
+function transcriptHref(item: HomepageRecentItem): string | null {
+ if (!item.siteUrl) return null;
+ return `${item.siteUrl}/?v=${encodeURIComponent(item.slug)}`;
+}
+
+// Newest transcriptions across every site, each badged with where it landed and
+// linked straight to the transcript.
+export function RecentAdditions({ items }: { items: HomepageRecentItem[] }) {
+ if (items.length === 0) return null;
+ return (
+ <section className="flex flex-col gap-3">
+ <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
+ Recent additions
+ </h2>
+ <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
+ {items.map((item) => {
+ const href = transcriptHref(item);
+ const title = (
+ <span className="font-medium text-zinc-900 dark:text-zinc-100 group-hover:text-blue-600 dark:group-hover:text-blue-400">
+ {item.title}
+ </span>
+ );
+ return (
+ <li key={`${item.siteId}:${item.slug}`}>
+ {/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
+ <a
+ href={href ?? undefined}
+ {...(href
+ ? { target: "_blank", rel: "noopener noreferrer" }
+ : { "aria-disabled": true })}
+ className="group flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
+ >
+ <div className="min-w-0">
+ {title}
+ <p className="truncate text-xs text-zinc-500">
+ {item.channel}
+ </p>
+ </div>
+ <div className="flex shrink-0 items-center gap-2 text-xs text-zinc-500">
+ <span className="rounded-full bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 font-medium text-zinc-600 dark:text-zinc-300">
+ {item.siteTitle}
+ </span>
+ <time dateTime={item.transcribedDate}>
+ {dayLabel(item.transcribedDate)}
+ </time>
+ </div>
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </section>
+ );
+}
diff --git a/homepage/app/components/SiteGrid.tsx b/homepage/app/components/SiteGrid.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary";
+import {
+ seriesColor,
+ type Breakdown,
+ type Metric,
+} from "yt-dlp-transcript-common/lib/homepageChart";
+
+// Tiny dependency-free sparkline: a soft area fill, the trend line, and a dot on
+// the latest point. Avoids mounting a Recharts instance per card (mobile perf).
+function Sparkline({ data, color }: { data: number[]; color: string }) {
+ const w = 104;
+ const h = 32;
+ const max = Math.max(1, ...data);
+ const n = data.length;
+ const pt = (v: number, i: number) => {
+ const x = n <= 1 ? w : (i / (n - 1)) * w;
+ const y = h - (v / max) * (h - 3) - 1.5;
+ return [x, y] as const;
+ };
+ const pts = data.map((v, i) => pt(v, i));
+ const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
+ const area = `0,${h} ${line} ${w},${h}`;
+ const [lx, ly] = pts[pts.length - 1] ?? [w, h];
+ const gid = `spark-${color.replace(/[^a-z0-9]/gi, "")}`;
+ return (
+ <svg
+ width={w}
+ height={h}
+ viewBox={`0 0 ${w} ${h}`}
+ className="overflow-visible"
+ aria-hidden
+ >
+ <defs>
+ <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stopColor={color} stopOpacity="0.28" />
+ <stop offset="100%" stopColor={color} stopOpacity="0" />
+ </linearGradient>
+ </defs>
+ <polygon points={area} fill={`url(#${gid})`} />
+ <polyline
+ points={line}
+ fill="none"
+ stroke={color}
+ strokeWidth={1.75}
+ strokeLinejoin="round"
+ strokeLinecap="round"
+ />
+ <circle cx={lx} cy={ly} r={2} fill={color} />
+ </svg>
+ );
+}
+
+function SiteCard({
+ site,
+ metric,
+ color,
+ isLeader,
+ bySite,
+ hidden,
+ onToggle,
+ delay,
+}: {
+ site: HomepageSummarySite;
+ metric: Metric;
+ color: string;
+ isLeader: boolean;
+ bySite: boolean;
+ hidden: boolean;
+ onToggle: () => void;
+ delay: number;
+}) {
+ const stat = site[metric];
+ const noun = metric === "downloaded" ? "downloads" : "transcripts";
+ const inner = (
+ <>
+ <div className={`flex items-start justify-between gap-2 ${bySite ? "pr-14" : ""}`}>
+ <div className="flex items-center gap-2.5 min-w-0">
+ {bySite && (
+ <span
+ className="mt-1 h-3 w-3 shrink-0 rounded-[3px]"
+ style={{ backgroundColor: color, boxShadow: `0 0 10px ${color}` }}
+ />
+ )}
+ <h3 className="truncate font-display text-lg font-semibold tracking-tight text-[var(--foreground)]">
+ {site.siteTitle}
+ </h3>
+ </div>
+ {isLeader && (
+ <span className="shrink-0 rounded-full border border-[var(--accent-soft)] bg-[var(--accent-soft)] px-2 py-0.5 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-[var(--accent-strong)]">
+ #1 this month
+ </span>
+ )}
+ </div>
+ {site.siteDescription && (
+ <p className="line-clamp-2 text-sm leading-relaxed text-[var(--muted-foreground)]">
+ {site.siteDescription}
+ </p>
+ )}
+ <div className="mt-auto flex items-end justify-between gap-2 pt-1">
+ <div className="flex flex-col gap-0.5">
+ <span className="font-display text-2xl font-semibold leading-none text-[var(--foreground)] tabular-nums">
+ {stat.total.toLocaleString()}
+ </span>
+ <span className="font-mono text-[0.625rem] uppercase tracking-[0.12em] text-[var(--faint)]">
+ {noun}
+ {stat.thisMonth > 0 && (
+ <span className="ml-1.5 text-[var(--accent)]">
+ +{stat.thisMonth.toLocaleString()} mo.
+ </span>
+ )}
+ </span>
+ </div>
+ <Sparkline data={stat.last12} color={color} />
+ </div>
+ </>
+ );
+
+ const cardClass =
+ "group relative flex flex-col gap-3 rounded-xl border border-[var(--border)] bg-[var(--panel)] p-5 backdrop-blur-sm transition duration-200 hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:bg-[var(--panel-2)]" +
+ (hidden ? " opacity-40" : "");
+
+ return (
+ <div
+ className={`reveal ${cardClass}`}
+ style={{ "--d": `${delay}ms` } as React.CSSProperties}
+ >
+ {bySite && (
+ <button
+ type="button"
+ onClick={onToggle}
+ aria-pressed={!hidden}
+ title={hidden ? "Show on chart" : "Hide from chart"}
+ className="absolute right-3 top-3 z-10 rounded-full border border-[var(--border)] bg-[rgba(20,16,9,0.85)] px-2 py-0.5 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-[var(--muted-foreground)] transition-colors hover:border-[var(--border-strong)] hover:text-[var(--foreground)]"
+ >
+ {hidden ? "Show" : "Hide"}
+ </button>
+ )}
+ <a
+ href={site.siteUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex flex-1 flex-col gap-3"
+ >
+ {inner}
+ </a>
+ </div>
+ );
+}
+
+// Responsive grid of public content sites. Card stats follow the active metric
+// and the grid sorts by that metric's "this month" count. In the By-site
+// breakdown the cards double as the chart legend (colored swatch + Show/Hide
+// toggle that filters the matching band); colors key on each site's index in
+// the unsorted `sites` list so they stay aligned with the chart.
+export function SiteGrid({
+ sites,
+ metric,
+ breakdown,
+ hidden,
+ onToggle,
+}: {
+ sites: HomepageSummarySite[];
+ metric: Metric;
+ breakdown: Breakdown;
+ hidden: ReadonlySet<string>;
+ onToggle: (siteId: string) => void;
+}) {
+ if (sites.length === 0) return null;
+ const bySite = breakdown === "site";
+ const colorIndex = new Map(sites.map((s, i) => [s.siteId, i]));
+
+ const ordered = [...sites].sort(
+ (a, b) =>
+ b[metric].thisMonth - a[metric].thisMonth ||
+ b[metric].total - a[metric].total ||
+ a.siteTitle.localeCompare(b.siteTitle),
+ );
+ const maxThisMonth = Math.max(0, ...sites.map((s) => s[metric].thisMonth));
+ const leaderId =
+ maxThisMonth > 0
+ ? ordered.find((s) => s[metric].thisMonth === maxThisMonth)?.siteId
+ : undefined;
+
+ return (
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
+ {ordered.map((site, i) => (
+ <SiteCard
+ key={site.siteId}
+ site={site}
+ metric={metric}
+ color={seriesColor(colorIndex.get(site.siteId) ?? 0)}
+ isLeader={site.siteId === leaderId}
+ bySite={bySite}
+ hidden={hidden.has(site.siteId)}
+ onToggle={() => onToggle(site.siteId)}
+ delay={i * 60}
+ />
+ ))}
+ </div>
+ );
+}
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -1,19 +1,35 @@
@import "tailwindcss";
@source "../../common/components";
+/* The hub is committed to one deliberate aesthetic — a warm-ink "archive room".
+ Force the dark variant onto a class so it never flips with the OS setting. */
+@custom-variant dark (&:where(.dark, .dark *));
+
:root {
- --background: #ffffff;
- --foreground: #171717;
- /* Semantic tokens consumed by shadcn/ui chart primitives. */
- --border: #e4e4e7;
- --muted: #f4f4f5;
- --muted-foreground: #71717a;
- /* Chart series palette. */
- --chart-1: #2563eb;
- --chart-2: #16a34a;
- --chart-3: #ea580c;
- --chart-4: #9333ea;
- --chart-5: #db2777;
+ /* Warm near-black ground + aged-paper ink. */
+ --background: #0c0a08;
+ --surface: #141009;
+ --foreground: #efe7d8;
+ /* Hairlines + muted speech. */
+ --border: rgba(233, 220, 197, 0.1);
+ --border-strong: rgba(233, 220, 197, 0.18);
+ --muted: #1c1710;
+ --muted-foreground: #a39a86;
+ --faint: #6f6757;
+ /* Glass panels. */
+ --panel: rgba(28, 23, 15, 0.55);
+ --panel-2: rgba(40, 33, 21, 0.5);
+ /* Brass signature accent. */
+ --accent: #e3b15c;
+ --accent-strong: #f3c977;
+ --accent-soft: rgba(227, 177, 92, 0.14);
+ --accent-ink: #1a1305;
+ /* Series palette, tuned to glow on ink. */
+ --chart-1: #6ea8ff;
+ --chart-2: #54d6a0;
+ --chart-3: #f2935b;
+ --chart-4: #c08cf0;
+ --chart-5: #f178b6;
}
@theme inline {
@@ -22,6 +38,7 @@
--color-border: var(--border);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -29,25 +46,93 @@
--color-chart-5: var(--chart-5);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
+ --font-display: var(--font-fraunces);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- --border: #27272a;
- --muted: #27272a;
- --muted-foreground: #a1a1aa;
- --chart-1: #60a5fa;
- --chart-2: #4ade80;
- --chart-3: #fb923c;
- --chart-4: #c084fc;
- --chart-5: #f472b6;
- }
+html {
+ background-color: var(--background);
}
body {
- background: var(--background);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Atmosphere: a warm overhead glow + a faint engraved grid, fixed behind all
+ content. Pointer-transparent and z-negative so nothing is obstructed. */
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: -2;
+ pointer-events: none;
+ background:
+ radial-gradient(
+ 120% 80% at 50% -10%,
+ rgba(227, 177, 92, 0.16),
+ rgba(227, 177, 92, 0) 60%
+ ),
+ radial-gradient(
+ 90% 60% at 85% 0%,
+ rgba(110, 168, 255, 0.08),
+ rgba(110, 168, 255, 0) 55%
+ ),
+ linear-gradient(180deg, #110d08 0%, var(--background) 38%, #08070500 100%);
+}
+
+body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ opacity: 0.05;
+ mix-blend-mode: soft-light;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+
+/* A faint hairline grid layer the page can opt into for the "ledger" feel. */
+.grid-ledger {
+ background-image:
+ linear-gradient(var(--border) 1px, transparent 1px),
+ linear-gradient(90deg, var(--border) 1px, transparent 1px);
+ background-size: 56px 56px;
+ background-position: center;
+ -webkit-mask-image: radial-gradient(120% 120% at 50% 0%, #000 35%, transparent 78%);
+ mask-image: radial-gradient(120% 120% at 50% 0%, #000 35%, transparent 78%);
+}
+
+/* One orchestrated page-load reveal. Children stagger via inline --d. */
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(14px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.reveal {
+ opacity: 0;
+ animation: rise 0.72s cubic-bezier(0.2, 0.7, 0.2, 1) both;
+ animation-delay: var(--d, 0ms);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .reveal {
+ animation: none;
+ opacity: 1;
+ }
+}
+
+/* Brass underline used under section eyebrows. */
+.rule-accent {
+ background: linear-gradient(
+ 90deg,
+ var(--accent) 0%,
+ rgba(227, 177, 92, 0) 70%
+ );
}
diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Geist, Geist_Mono, Fraunces } from "next/font/google";
import { currentHomepage } from "./lib/homepage";
import Header from "./components/Header";
import Footer from "./components/Footer";
@@ -15,6 +15,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
+// Distinctive high-contrast serif for the wordmark, headline numerals and
+// section heads — the "editorial archive" voice.
+const fraunces = Fraunces({
+ variable: "--font-fraunces",
+ subsets: ["latin"],
+ axes: ["opsz", "SOFT"],
+ style: ["normal", "italic"],
+});
+
export function generateMetadata(): Metadata {
const c = currentHomepage();
return {
@@ -34,11 +43,11 @@ export default function RootLayout({
return (
<html
lang="en"
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
+ className={`dark ${geistSans.variable} ${geistMono.variable} ${fraunces.variable} h-full antialiased`}
>
- <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans">
+ <body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--accent-soft)] selection:text-[var(--accent-strong)]">
<Header />
- <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6">
+ <main className="flex-1 w-full max-w-6xl mx-auto px-5 sm:px-6 py-8 sm:py-12">
{children}
</main>
<Footer />
diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx
@@ -1,42 +1,71 @@
-import Link from "next/link";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { HOMEPAGE_INDEX_SLUG } from "yt-dlp-transcript-common/lib/homepage";
-import { getHomepagePage } from "yt-dlp-transcript-common/lib/homepagePages";
+import fs from "node:fs";
+import path from "node:path";
+import {
+ HOMEPAGE_SUMMARY_VERSION,
+ type HomepageSummary,
+} from "yt-dlp-transcript-common/lib/homepageSummary";
import { currentHomepage } from "./lib/homepage";
-import PageBody from "./components/PageBody";
+import { HomeLanding } from "./components/HomeLanding";
+
+// The hub home route IS the cross-site landing: a branding line, headline KPIs,
+// one big "transcriptions over time" chart with a minimal control strip, the
+// site grid, and a recent-additions feed. All of it renders from a small
+// summary pre-computed at build (compose-homepage writes public/homepage-
+// summary.json), so the page is static HTML with no multi-MB client fetch.
+
+function emptySummary(): HomepageSummary {
+ const emptyBucket = { buckets: [], total: [], bySite: {}, byChannel: {} };
+ const emptyMetric = { week: emptyBucket, month: emptyBucket };
+ return {
+ version: HOMEPAGE_SUMMARY_VERSION,
+ generatedAt: "",
+ totals: {
+ transcripts: 0,
+ downloads: 0,
+ sites: 0,
+ channels: 0,
+ hoursArchived: 0,
+ transcribedThisMonth: 0,
+ downloadedThisMonth: 0,
+ },
+ channels: [],
+ series: { transcribed: emptyMetric, downloaded: emptyMetric },
+ sites: [],
+ recent: [],
+ };
+}
+
+// Read the build-time summary from public/. Absent in plain `next dev` (compose
+// hasn't run) — fall back to an empty summary so the page still renders.
+function loadSummary(): HomepageSummary {
+ try {
+ const file = path.join(process.cwd(), "public", "homepage-summary.json");
+ return JSON.parse(fs.readFileSync(file, "utf8")) as HomepageSummary;
+ } catch {
+ return emptySummary();
+ }
+}
-// Home route. Shows a branding hero (title + tagline) followed by the "index"
-// Markdown page body when one is authored; otherwise a minimal default that
-// points at the docs nav and the cross-site stats dashboard.
export default function Home() {
const config = currentHomepage();
- const index = getHomepagePage(HOMEPAGE_INDEX_SLUG, getPaths());
+ const summary = loadSummary();
return (
- <div className="flex flex-col gap-8">
- <section className="flex flex-col gap-3">
- <h1 className="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
+ <div className="flex flex-col gap-10 sm:gap-14">
+ <section className="reveal relative flex flex-col gap-4 pt-2">
+ <div className="flex items-center gap-3">
+ <span className="h-px w-8 rule-accent" />
+ <span className="font-mono text-[0.6875rem] uppercase tracking-[0.28em] text-[var(--accent)]">
+ The archive room
+ </span>
+ </div>
+ <h1 className="font-display text-5xl sm:text-7xl font-semibold leading-[0.95] tracking-[-0.02em] text-[var(--foreground)]">
{config.siteTitle}
</h1>
- {config.homeTagline ? (
- <p className="text-lg text-zinc-600 dark:text-zinc-400">
- {config.homeTagline}
- </p>
- ) : (
- <p className="text-lg text-zinc-600 dark:text-zinc-400">
- {config.siteDescription}
- </p>
- )}
- <div>
- <Link
- href="/stats"
- className="inline-block rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
- >
- View cross-site stats
- </Link>
- </div>
+ <p className="max-w-2xl font-display text-xl sm:text-2xl italic leading-snug text-[var(--muted-foreground)]">
+ {config.homeTagline || config.siteDescription}
+ </p>
</section>
-
- {index && <PageBody body={index.body} />}
+ <HomeLanding summary={summary} />
</div>
);
}
diff --git a/homepage/app/stats/HomepageChartsClient.tsx b/homepage/app/stats/HomepageChartsClient.tsx
@@ -1,70 +0,0 @@
-"use client";
-
-import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
-import { useStats } from "yt-dlp-transcript-common/components/statsCache";
-import { ChartsDashboard } from "yt-dlp-transcript-common/components/charts/ChartsDashboard";
-import {
- ChannelSitesProvider,
- type ChannelSitesMap,
-} from "yt-dlp-transcript-common/components/charts/channelSites";
-import {
- PRESET_GROUPS,
- type Dashboard,
-} from "yt-dlp-transcript-common/lib/chartConfig";
-
-// The hub has no transcript/search index, so the search-derived gallery group
-// (which needs one) is dropped. Everything else (overview + content-added) works
-// off the whole-pool stats dataset alone.
-const HUB_GALLERY = PRESET_GROUPS.filter((g) => g.id !== "search");
-
-function Inner({ initialDashboard }: { initialDashboard: Dashboard }) {
- const { stats, ready, loadedPages, pageCount, manifest, error } = useStats();
-
- if (error) {
- return (
- <p className="text-sm text-red-600 dark:text-red-400">
- Failed to load the stats dataset ({error.message}).
- </p>
- );
- }
- if (!ready) {
- return (
- <p className="text-sm text-zinc-500">
- Loading stats…{" "}
- {pageCount > 0 ? `(${loadedPages}/${pageCount} pages)` : ""}
- </p>
- );
- }
-
- const channels = (manifest?.channels ?? []).map((c) => ({
- slug: c.slug,
- name: c.name,
- }));
-
- return (
- <ChartsDashboard
- initialDashboard={initialDashboard}
- gallery={HUB_GALLERY}
- stats={stats}
- summaries={[]}
- channels={channels}
- syncUrl
- />
- );
-}
-
-export function HomepageChartsClient({
- initialDashboard,
- channelSites,
-}: {
- initialDashboard: Dashboard;
- channelSites: ChannelSitesMap;
-}) {
- return (
- <QueryProvider>
- <ChannelSitesProvider value={channelSites}>
- <Inner initialDashboard={initialDashboard} />
- </ChannelSitesProvider>
- </QueryProvider>
- );
-}
diff --git a/homepage/app/stats/page.tsx b/homepage/app/stats/page.tsx
@@ -1,36 +0,0 @@
-import type { Metadata } from "next";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { readHomepageTemplates } from "yt-dlp-transcript-common/lib/chartsStore";
-import { listSites } from "yt-dlp-transcript-common/lib/site";
-import type { ChannelSitesMap } from "yt-dlp-transcript-common/components/charts/channelSites";
-import { HomepageChartsClient } from "./HomepageChartsClient";
-
-export const metadata: Metadata = { title: "Stats" };
-
-// Cross-site charts dashboard. The dataset (whole-pool stats) is fetched
-// client-side from /stats/ (staged by compose-homepage); the channel→sites map
-// for `groupBy: "site"` is computed here at build from the live site configs and
-// passed down to the chart context.
-export default function StatsPage() {
- const paths = getPaths();
- const templates = readHomepageTemplates(paths);
- const channelSites: ChannelSitesMap = {};
- for (const site of listSites(paths)) {
- for (const c of site.channels) {
- (channelSites[c.slug] ??= []).push(site.siteId);
- }
- }
- return (
- <div className="flex flex-col gap-4">
- <h1 className="text-2xl font-semibold">Across all sites</h1>
- <p className="text-sm text-zinc-500">
- Downloads and transcriptions completed across every content site in this
- instance.
- </p>
- <HomepageChartsClient
- initialDashboard={templates.defaultDashboard}
- channelSites={channelSites}
- />
- </div>
- );
-}