Archilyzer · Source

archilyzer

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

commit acc9d76e295da35d77a65e95266a5a01b8d4193a
parent 6b6e79cfdc79a17c4c2859d560db451bb69482d3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 28 Jun 2026 21:55:36 -0400

feat(homepage): cross-site landing hub + drop Markdown pages

Replace the hub's thin hero + separate /stats dashboard with a single,
mobile-first cross-site landing rendered from a small build-time summary
(compose-homepage writes the gitignored homepage-summary.json), and remove
the hub Markdown-pages subsystem and editor page-creator entirely.

Landing:
- KPI header (transcripts, downloads, sites, channels, hours archived).
- One stacked activity chart with five segmented controls: Metric
  (Transcribed/Downloaded), Breakdown (By site/By channel, top-8+Other),
  Bucket (Week/Month/Cumulative), Range (90d/12mo/All), Display
  (Share 100%-stacked / Counts). Week/Month = stacked bars, Cumulative =
  stacked area; entry animation disabled so it paints immediately.
- Site grid: public sites only, metric-aware cards sorted by "+this month",
  sparklines, "#1 this month" badge, cards double as the chart legend with
  Show/Hide toggles.
- Recent-additions feed still built into the summary but not rendered (kept
  as a component for easy re-enable).

Design: committed warm-ink "archive room" theme — Fraunces serif + Geist
Mono, brass accent, atmospheric background, glass panels, staggered reveal;
dark-committed via a class-based dark variant.

Data: new common/lib/homepageSummary.ts (public-site-only, single primary-site
attribution, dual metric, week+month buckets, per-site & per-channel series)
and common/lib/homepageChart.ts; CrossSiteChart moved into common/components.

Removed: /stats route + HomepageChartsClient, app/[slug] doc route, PageBody,
common/lib/{homepagePages,homepageConstants}.ts, paths.homepagePagesDir, the
homepage config nav, and the editor homepage page-creator (pages routes,
PageEditor, page server actions) — branding config form kept.

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

Diffstat:
M.gitignore | 1+
Mcommon/bin/compose-homepage.ts | 60+++++++++++++++++++++++++++++++++++++++++++++++++++---------
Acommon/components/charts/CrossSiteChart.tsx | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/homepage.ts | 64++++++++--------------------------------------------------------
Acommon/lib/homepageChart.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Dcommon/lib/homepageConstants.ts | 23-----------------------
Dcommon/lib/homepagePages.ts | 124-------------------------------------------------------------------------------
Acommon/lib/homepageSummary.ts | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/paths.ts | 8+++-----
Meditor/CHANGELOG.md | 1+
Deditor/app/homepage/PageEditor.tsx | 157-------------------------------------------------------------------------------
Meditor/app/homepage/actions.ts | 72++----------------------------------------------------------------------
Meditor/app/homepage/page.tsx | 44+++-----------------------------------------
Deditor/app/homepage/pages/[slug]/page.tsx | 41-----------------------------------------
Deditor/app/homepage/pages/new/page.tsx | 21---------------------
Mhomepage/CHANGELOG.md | 38++++++++++++++++++++++++++++++++++++++
Dhomepage/app/[slug]/page.tsx | 49-------------------------------------------------
Mhomepage/app/components/Footer.tsx | 12+++++++-----
Mhomepage/app/components/Header.tsx | 52++++++++++++----------------------------------------
Ahomepage/app/components/HomeLanding.tsx | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/components/KpiHeader.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dhomepage/app/components/PageBody.tsx | 85-------------------------------------------------------------------------------
Ahomepage/app/components/RecentAdditions.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/components/SiteGrid.tsx | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/globals.css | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mhomepage/app/layout.tsx | 17+++++++++++++----
Mhomepage/app/page.tsx | 91++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Dhomepage/app/stats/HomepageChartsClient.tsx | 70----------------------------------------------------------------------
Dhomepage/app/stats/page.tsx | 36------------------------------------
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> - ); -}