commit e5994b34c7951a2f33c3f808b18326034caf767e
parent 91e4583e2c1517cc7b2f7ff9f24c758724f60427
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 13:00:12 -0400
dashboard: the shared channel table; the widgetActionable copy folded
app/page.tsx builds `ChannelRowView`s with the shared builder (no bands,
neutral priority, the media label still named from the media target) and
seeds "Needs work" with `buildWidgetActionablePayload(widgetActionableRows(
rows))` — the two functions the /api/view/widgetActionable handler serves —
instead of a hand copy of the filter and sort. DashboardCockpit draws the
shared ChannelsTable with DASHBOARD_COLUMNS, `rowLabelPrefix="channel"`, no
sort, no selection; the row's actions are ChannelSyncButton (replacing the
private InlineSyncButton, same `sync <slug>` name), Download missing, and
PrioritizeButton, moved verbatim to components/actions/. Deleted:
components/dashboard/ChannelsTable.tsx and types.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 142 insertions(+), 279 deletions(-)
diff --git a/editor/app/components/actions/PrioritizeButton.tsx b/editor/app/components/actions/PrioritizeButton.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { prioritizeChannelDownloadAction } from "../../operations/actions";
+
+// One-click "Top of queue": prepend this channel to the auto-download policy and
+// start the runner (prioritizeChannelDownloadAction).
+export function PrioritizeButton({ slug }: { slug: string }) {
+ const [pending, startTransition] = useTransition();
+ const [done, setDone] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ function handleClick() {
+ setError(null);
+ startTransition(async () => {
+ const res = await prioritizeChannelDownloadAction(slug);
+ if (res.ok) setDone(true);
+ else setError(res.error);
+ });
+ }
+
+ return (
+ <span className="inline-flex items-center gap-1.5">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={pending || done}
+ aria-label={`prioritize ${slug} for download`}
+ title="Prepend to the auto-download queue and start the runner"
+ className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
+ >
+ {done ? "Queued ↑" : pending ? "…" : "Top of queue"}
+ </button>
+ {error && (
+ <span role="alert" className="text-xs text-destructive">
+ {error}
+ </span>
+ )}
+ </span>
+ );
+}
diff --git a/editor/app/components/dashboard/ChannelsTable.tsx b/editor/app/components/dashboard/ChannelsTable.tsx
@@ -1,192 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useState, useTransition } from "react";
-import { syncAction } from "../../channels/[slug]/pipelineActions";
-import { prioritizeChannelDownloadAction } from "../../operations/actions";
-import { InlineActionButton } from "../actions/InlineActionButton";
-import { fmtTime } from "../../widget/lib/relativeTime";
-import type { DashboardChannel } from "./types";
-import { MediaLocationBadge } from "../MediaLocationBadge";
-
-// The enriched channels table: the plain slug/handling/videos list plus a
-// relative "last sync" that ticks live, and per-row inline actions (Sync,
-// Download missing, and a one-click "Top of queue" that prioritizes the channel
-// for auto-download).
-export function ChannelsTable({
- channels,
- now,
-}: {
- channels: DashboardChannel[];
- now: number | null;
-}) {
- if (channels.length === 0) return null;
- return (
- <section className="flex flex-col gap-3">
- <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- Channels
- </h2>
- <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
- <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full">
- <thead className="bg-muted">
- <tr>
- <th className="text-left font-medium px-3 py-2">Slug</th>
- <th className="text-left font-medium px-3 py-2">Handling</th>
- <th className="text-right font-medium px-3 py-2">Videos</th>
- <th
- className="text-right font-medium px-3 py-2 whitespace-nowrap"
- title="Videos the digest lane can work on now — the same reachable figure the /operations rail draws; blocked and deferred are not in it"
- >
- Digest to do
- </th>
- <th className="text-left font-medium px-3 py-2 whitespace-nowrap">
- Last sync
- </th>
- <th className="text-left font-medium px-3 py-2">Actions</th>
- </tr>
- </thead>
- <tbody>
- {channels.map((c) => (
- <tr
- key={c.slug}
- aria-label={`channel row ${c.slug}`}
- className="border-t border-border align-top"
- >
- <td className="px-3 py-2 font-mono">
- <span className="inline-flex items-center gap-1.5">
- <Link
- href={`/channels/${c.slug}`}
- className="underline underline-offset-2 hover:text-brand transition-colors"
- >
- {c.slug}
- </Link>
- <MediaLocationBadge media={c.media} compact />
- </span>
- </td>
- <td className="px-3 py-2">{c.handling}</td>
- <td className="px-3 py-2 text-right tabular-nums">
- {c.videoCount}
- </td>
- {/* Muted: during the backfill this is nearly every video, so
- it is a coverage readout rather than a call to action. */}
- <td className="px-3 py-2 text-right tabular-nums text-xs text-muted-foreground">
- {c.digestReachable}
- </td>
- <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap tabular-nums">
- {c.lastSyncedAt == null
- ? "never"
- : (fmtTime(c.lastSyncedAt, now, false) ?? "…")}
- </td>
- <td className="px-3 py-2">
- <div className="flex flex-wrap items-center gap-1.5">
- {c.hasUrl ? (
- <>
- <InlineSyncButton slug={c.slug} />
- {c.undownloaded > 0 && (
- <InlineActionButton
- variant={{ kind: "downloadMissing", slug: c.slug }}
- />
- )}
- <PrioritizeButton slug={c.slug} />
- </>
- ) : (
- <span className="text-xs text-muted-foreground">
- no URL
- </span>
- )}
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </section>
- );
-}
-
-// Single-channel Sync: kicks the streaming sync and drains the stream so the job
-// keeps running server-side while this row returns to idle.
-function InlineSyncButton({ slug }: { slug: string }) {
- const [status, setStatus] = useState<"idle" | "running" | "done" | "error">(
- "idle",
- );
- const [message, setMessage] = useState<string | null>(null);
-
- async function handleClick() {
- setStatus("running");
- setMessage(null);
- try {
- const res = await syncAction(slug);
- if (!res.ok) {
- setStatus("error");
- setMessage(res.error);
- return;
- }
- void res.stream.cancel();
- setStatus("done");
- } catch (e) {
- setStatus("error");
- setMessage((e as Error).message);
- }
- }
-
- return (
- <span className="inline-flex items-center gap-1.5">
- <button
- type="button"
- onClick={handleClick}
- disabled={status === "running"}
- aria-label={`sync ${slug}`}
- className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
- >
- {status === "running" ? "Syncing…" : "Sync"}
- </button>
- {status === "done" && (
- <span className="text-xs text-muted-foreground">queued</span>
- )}
- {status === "error" && message && (
- <span role="alert" className="text-xs text-destructive">
- {message}
- </span>
- )}
- </span>
- );
-}
-
-// One-click "Top of queue": prepend this channel to the auto-download policy and
-// start the runner (prioritizeChannelDownloadAction).
-function PrioritizeButton({ slug }: { slug: string }) {
- const [pending, startTransition] = useTransition();
- const [done, setDone] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- function handleClick() {
- setError(null);
- startTransition(async () => {
- const res = await prioritizeChannelDownloadAction(slug);
- if (res.ok) setDone(true);
- else setError(res.error);
- });
- }
-
- return (
- <span className="inline-flex items-center gap-1.5">
- <button
- type="button"
- onClick={handleClick}
- disabled={pending || done}
- aria-label={`prioritize ${slug} for download`}
- title="Prepend to the auto-download queue and start the runner"
- className="px-2 py-1 rounded-md border border-border text-xs hover:bg-muted disabled:opacity-50 whitespace-nowrap"
- >
- {done ? "Queued ↑" : pending ? "…" : "Top of queue"}
- </button>
- {error && (
- <span role="alert" className="text-xs text-destructive">
- {error}
- </span>
- )}
- </span>
- );
-}
diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx
@@ -8,8 +8,15 @@ import { usePolledPayload, useNow } from "../../lib/usePolledPayload";
import { PipelineBand } from "./PipelineBand";
import { NeedsWorkPanel } from "./NeedsWorkPanel";
import { QuickAddPanel } from "./QuickAddPanel";
-import { ChannelsTable } from "./ChannelsTable";
-import type { DashboardChannel } from "./types";
+import type { ChannelRowView } from "yt-dlp-transcript-common/views/channelRow";
+import { ChannelsTable } from "../../channels/components/ChannelsTable";
+import {
+ DASHBOARD_COLUMNS,
+ type ChannelRowExtras,
+} from "../../channels/components/channelColumns";
+import { ChannelSyncButton } from "../../channels/components/ChannelSyncButton";
+import { InlineActionButton } from "../actions/InlineActionButton";
+import { PrioritizeButton } from "../actions/PrioritizeButton";
// Poll cadences mirror the monitor widget: job/worker state moves by the second;
// the worklist and sync/scheduler markers move by the minute, so they poll no
@@ -22,7 +29,9 @@ export type DashboardCockpitInitial = {
workers: WorkersPayload;
actionable: WidgetActionablePayload;
sync: WidgetSyncPayload;
- channels: DashboardChannel[];
+ // Built server-side in app/page.tsx and passed straight through — never into
+ // client state — so a global AutoRefresh re-seeds them live.
+ channels: ChannelRowView[];
changelogSource: string | null;
};
@@ -78,7 +87,59 @@ export function DashboardCockpit({
<NeedsWorkPanel data={actionable} />
<QuickAddPanel changelogSource={initial.changelogSource} />
</div>
- <ChannelsTable channels={initial.channels} now={now} />
+ <DashboardChannels channels={initial.channels} now={now} />
</div>
);
}
+
+// The dashboard's channels table: the shared channel table with six columns,
+// a relative "last sync" that ticks live, and per-row inline actions (Sync,
+// Download missing, and a one-click "Top of queue" that prioritizes the channel
+// for auto-download).
+function DashboardChannels({
+ channels,
+ now,
+}: {
+ channels: ChannelRowView[];
+ now: number | null;
+}) {
+ if (channels.length === 0) return null;
+ const rowExtras: Record<string, ChannelRowExtras> = {};
+ for (const c of channels) {
+ rowExtras[c.slug] = {
+ actions: (
+ <div className="flex flex-wrap items-center gap-1.5">
+ {c.hasUrl ? (
+ <>
+ <ChannelSyncButton slug={c.slug} />
+ {c.work.undownloaded > 0 && (
+ <InlineActionButton
+ variant={{ kind: "downloadMissing", slug: c.slug }}
+ />
+ )}
+ <PrioritizeButton slug={c.slug} />
+ </>
+ ) : (
+ <span className="text-xs text-muted-foreground">no URL</span>
+ )}
+ </div>
+ ),
+ };
+ }
+ return (
+ <section className="flex flex-col gap-3">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ Channels
+ </h2>
+ <ChannelsTable
+ rows={channels}
+ columns={DASHBOARD_COLUMNS}
+ rowLabelPrefix="channel"
+ rowExtras={rowExtras}
+ sort={false}
+ selection={false}
+ now={now}
+ />
+ </section>
+ );
+}
diff --git a/editor/app/components/dashboard/types.ts b/editor/app/components/dashboard/types.ts
@@ -1,30 +0,0 @@
-import type { MediaBadgeInput } from "../MediaLocationBadge";
-
-// Enriched per-channel row for the dashboard cockpit's Channels table. Built
-// server-side in app/page.tsx from the actionable summary and passed straight
-// through (not into client state), so a global AutoRefresh re-seeds it live.
-export type DashboardChannel = {
- slug: string;
- handling: string;
- videoCount: number;
- // Epoch ms of the channel's last sync, or null when it has never synced.
- lastSyncedAt: number | null;
- // Whether the channel has a URL — inline Sync / Download need one.
- hasUrl: boolean;
- undownloaded: number;
- untranscribed: number;
- // The digest band's reachable — what the lane could act on today; not
- // blocked, not deferred. Missing, stale or part-done at the CURRENT identity,
- // from the operation registry. The only per-channel number that makes corpus
- // coverage legible while a multi-week sweep is running.
- //
- // Videos with no transcript are NOT in it: they are classified as waiting on
- // transcription and counted separately.
- digestReachable: number;
- // Where this channel's media physically is, when that is not "in the channel
- // directory". Null for an in-place channel — the overwhelming majority — so
- // the badge marks only the rows whose other numbers may not be trustworthy:
- // an unmounted drive reads as "nothing downloaded" to every count on this
- // row. See app/components/MediaLocationBadge.tsx.
- media: MediaBadgeInput | null;
-};
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -10,10 +10,8 @@ import {
siteChannelSlugs,
} from "yt-dlp-transcript-common/lib/site";
import {
- actionableDigestReachableCount,
- actionableUndownloadedCount,
- actionableUntranscribedCount,
getActionableSummary,
+ widgetActionableRows,
} from "./lib/actionable/loadActionable";
import { resolveActiveSite } from "./lib/activeSite";
import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs";
@@ -21,8 +19,13 @@ import { buildWorkersPayload } from "./workers/buildWorkers";
import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
import { widgetSyncInputs } from "./widget/lib/syncInputs";
import { DashboardCockpit } from "./components/dashboard/DashboardCockpit";
-import type { DashboardChannel } from "./components/dashboard/types";
-import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
+import {
+ buildChannelRowView,
+ channelVolumeOf,
+ neutralChannelPriority,
+ type ChannelRowView,
+} from "yt-dlp-transcript-common/views/channelRow";
+import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { ActionableRow } from "./lib/actionable/loadActionable";
export const dynamic = "force-dynamic";
@@ -43,12 +46,6 @@ function loadChangelog(): string | null {
}
}
-function toMs(iso: string | undefined): number | null {
- if (!iso) return null;
- const n = Date.parse(iso);
- return Number.isNaN(n) ? null : n;
-}
-
export default async function Dashboard({
searchParams,
}: {
@@ -87,49 +84,35 @@ export default async function Dashboard({
);
// Read once for the whole table, not once per row.
const locations = getSettings().storage.locations;
- const channels: DashboardChannel[] = rows.map((r) => ({
- slug: r.channel.slug,
- handling: r.channel.config.handling,
- // From the snapshot rather than a corpus walk, so it lags a sync by the
- // snapshot scheduler's debounce.
- videoCount: r.snapshot?.totals.videos ?? 0,
- lastSyncedAt: toMs(r.channel.config.lastSyncedAt),
- hasUrl: Boolean(r.channel.config.url),
- undownloaded: actionableUndownloadedCount(r),
- untranscribed: actionableUntranscribedCount(r),
- digestReachable: actionableDigestReachableCount(r),
- media: (() => {
- const m = mediaBySlug.get(r.channel.slug);
- return m && m.status !== "in-place"
- ? {
- status: m.status,
- target: m.target,
- detail: m.detail,
- // The named location, matched against the configured roots here
- // because the settings are here and the table is a client
- // component. A pure prefix match, never a probe.
- locationLabel: locationLabelOfDataDir(m.target, locations),
- }
- : null;
- })(),
- }));
+ // The shared channel row (common/views/channelRow.ts), drawn by the shared
+ // table with the dashboard's six columns. No pipeline bands and a neutral
+ // priority: this table draws neither.
+ const channels: ChannelRowView[] = rows.map((r) => {
+ const media = mediaBySlug.get(r.channel.slug) ?? null;
+ return buildChannelRowView({
+ slug: r.channel.slug,
+ config: r.channel.config,
+ // From the snapshot rather than a corpus walk, so the Videos figure lags
+ // a sync by the snapshot scheduler's debounce.
+ snapshot: r.snapshot,
+ playlistCount: null,
+ bands: [],
+ priority: neutralChannelPriority(),
+ media,
+ volume: channelVolumeOf(r.channel.config.dataDir, locations),
+ // The named location, matched against the configured roots from the
+ // media TARGET, as this table always has. A pure prefix match, never a
+ // probe.
+ mediaLocationLabel: media
+ ? locationLabelOfDataDir(media.target, locations)
+ : undefined,
+ });
+ });
- // "Needs work" payload, same shape the widget endpoint the cockpit polls
- // returns, scoped to the active site for the SSR seed.
- const actionable: WidgetActionablePayload = {
- channels: channels
- .filter((c) => c.undownloaded > 0 || c.untranscribed > 0)
- .map((c) => ({
- slug: c.slug,
- undownloaded: c.undownloaded,
- untranscribed: c.untranscribed,
- digestReachable: c.digestReachable,
- }))
- .sort(
- (a, b) =>
- b.undownloaded + b.untranscribed - (a.undownloaded + a.untranscribed),
- ),
- };
+ // "Needs work" payload, same shape (and the same two functions) the
+ // /api/view/widgetActionable handler the cockpit polls serves, scoped to the
+ // active site for the SSR seed.
+ const actionable = buildWidgetActionablePayload(widgetActionableRows(rows));
const [jobs, sync] = await Promise.all([
buildActiveJobsPayload(),