commit 71dbb91b579312e8df32a2b0f235bdf69e4dc58c
parent 9432cc210af3cca0c483b4eb06ce436afa55e7ea
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 11 May 2026 20:59:27 -0400
Rethink UI
Diffstat:
8 files changed, 290 insertions(+), 154 deletions(-)
diff --git a/editor/app/channels/[slug]/components/StageMasterDetail.tsx b/editor/app/channels/[slug]/components/StageMasterDetail.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useSyncExternalStore, type ReactNode } from "react";
+import type { StageId, StageStatus, StageTone } from "../lib/stageStatus";
+import { StageRailItem } from "./StageRailItem";
+
+const TONE_HEADING_BORDER: Record<StageTone, string> = {
+ neutral: "border-zinc-200 dark:border-zinc-800",
+ attention: "border-amber-300 dark:border-amber-800",
+ danger: "border-red-300 dark:border-red-800",
+ running: "border-blue-300 dark:border-blue-800",
+ ok: "border-emerald-300 dark:border-emerald-800",
+};
+
+type Props = {
+ stages: Record<StageId, StageStatus>;
+ stageOrder: StageId[];
+ panels: Partial<Record<StageId, ReactNode>>;
+};
+
+function subscribeHash(callback: () => void) {
+ window.addEventListener("hashchange", callback);
+ return () => window.removeEventListener("hashchange", callback);
+}
+
+function getHash(): string {
+ if (typeof window === "undefined") return "";
+ return window.location.hash;
+}
+
+function getServerHash(): string {
+ return "";
+}
+
+export function StageMasterDetail({ stages, stageOrder, panels }: Props) {
+ const hash = useSyncExternalStore(subscribeHash, getHash, getServerHash);
+ const defaultStage =
+ stageOrder.find((id) => stages[id].defaultOpen) ?? stageOrder[0];
+ const hashMatch = /^#stage-(.+)$/.exec(hash);
+ const fromHash = hashMatch
+ ? (stageOrder.find((id) => id === hashMatch[1]) ?? null)
+ : null;
+ const selected = fromHash ?? defaultStage;
+ const status = stages[selected];
+ const isDanger = selected === "danger";
+
+ return (
+ <div className="grid grid-cols-[14rem_minmax(0,1fr)] gap-6">
+ <nav
+ aria-label="Pipeline stages"
+ className="flex flex-col gap-0.5 sticky top-32 self-start"
+ >
+ {stageOrder.map((id) => (
+ <StageRailItem
+ key={id}
+ status={stages[id]}
+ active={id === selected}
+ href={`#stage-${id}`}
+ />
+ ))}
+ </nav>
+ <section
+ aria-labelledby={`stage-${selected}-heading`}
+ className={`rounded-md border bg-white dark:bg-zinc-900 ${TONE_HEADING_BORDER[status.tone]}`}
+ >
+ <header className="flex items-baseline gap-3 flex-wrap px-4 py-3 border-b border-zinc-100 dark:border-zinc-800">
+ <h2
+ id={`stage-${selected}-heading`}
+ className={`text-lg font-semibold ${isDanger ? "text-red-700 dark:text-red-400" : ""}`}
+ >
+ {status.title}
+ </h2>
+ <span className="text-sm text-zinc-500">{status.summary}</span>
+ </header>
+ <div className="px-4 py-4 flex flex-col gap-4">{panels[selected]}</div>
+ </section>
+ </div>
+ );
+}
diff --git a/editor/app/channels/[slug]/components/StageRailItem.tsx b/editor/app/channels/[slug]/components/StageRailItem.tsx
@@ -0,0 +1,75 @@
+import type { StageStatus, StageTone } from "../lib/stageStatus";
+
+const TONE_ACCENT: Record<StageTone, string> = {
+ neutral: "text-zinc-700 dark:text-zinc-300",
+ attention: "text-amber-700 dark:text-amber-300",
+ danger: "text-red-700 dark:text-red-300",
+ running: "text-blue-700 dark:text-blue-300",
+ ok: "text-emerald-700 dark:text-emerald-300",
+};
+
+const TONE_ACTIVE_BG: Record<StageTone, string> = {
+ neutral: "bg-zinc-100 dark:bg-zinc-800",
+ attention: "bg-amber-50 dark:bg-amber-950",
+ danger: "bg-red-50 dark:bg-red-950",
+ running: "bg-blue-50 dark:bg-blue-950",
+ ok: "bg-emerald-50 dark:bg-emerald-950",
+};
+
+const TONE_ACTIVE_BORDER: Record<StageTone, string> = {
+ neutral: "border-zinc-400 dark:border-zinc-500",
+ attention: "border-amber-400 dark:border-amber-600",
+ danger: "border-red-400 dark:border-red-600",
+ running: "border-blue-400 dark:border-blue-600",
+ ok: "border-emerald-400 dark:border-emerald-600",
+};
+
+type Props = {
+ status: StageStatus;
+ active: boolean;
+ href: string;
+ onSelect?: () => void;
+};
+
+export function StageRailItem({ status, active, href, onSelect }: Props) {
+ const bg = active ? TONE_ACTIVE_BG[status.tone] : "hover:bg-zinc-100 dark:hover:bg-zinc-800/60";
+ const border = active
+ ? `border-l-2 ${TONE_ACTIVE_BORDER[status.tone]}`
+ : "border-l-2 border-transparent";
+ return (
+ <a
+ href={href}
+ aria-current={active ? "page" : undefined}
+ onClick={onSelect}
+ className={`flex items-center gap-2 pl-3 pr-2 py-2 rounded-r-md text-sm ${bg} ${border}`}
+ >
+ {status.running && (
+ <span
+ aria-hidden="true"
+ className="inline-block w-2 h-2 rounded-full bg-blue-500 animate-pulse shrink-0"
+ />
+ )}
+ <span className={`truncate ${active ? "font-semibold" : ""} ${TONE_ACCENT[status.tone]}`}>
+ {status.title}
+ </span>
+ <span className="ml-auto flex items-center gap-1">
+ {status.pending > 0 && (
+ <span
+ aria-label={`${status.title} pending count`}
+ className="font-mono text-xs px-1.5 rounded bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200"
+ >
+ {status.pending}
+ </span>
+ )}
+ {status.failed > 0 && (
+ <span
+ aria-label={`${status.title} failed count`}
+ className="font-mono text-xs px-1.5 rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
+ >
+ {status.failed}
+ </span>
+ )}
+ </span>
+ </a>
+ );
+}
diff --git a/editor/app/channels/[slug]/components/StatusHeader.tsx b/editor/app/channels/[slug]/components/StatusHeader.tsx
@@ -69,7 +69,7 @@ export function StatusHeader({
</div>
<div
aria-label="pipeline stage badges"
- className="flex flex-wrap gap-2"
+ className="flex flex-wrap gap-2 lg:hidden"
>
{badgeIds.map((id) => (
<StageBadge key={id} status={stages[id]} />
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
@@ -17,6 +18,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { ChannelFormClient } from "../components/ChannelFormClient";
import { DeleteChannelForm } from "../components/DeleteChannelForm";
import { PipelineStageCard } from "./components/PipelineStageCard";
+import { StageMasterDetail } from "./components/StageMasterDetail";
import { StatusHeader } from "./components/StatusHeader";
import { CleanupStage } from "./components/stages/CleanupStage";
import { DiagnosticsStage } from "./components/stages/DiagnosticsStage";
@@ -24,7 +26,11 @@ import { DownloadStage } from "./components/stages/DownloadStage";
import { PlaylistStage } from "./components/stages/PlaylistStage";
import { TranscodeStage } from "./components/stages/TranscodeStage";
import { TranscribeStage } from "./components/stages/TranscribeStage";
-import { computeStageStatuses, normalizeBuckets } from "./lib/stageStatus";
+import {
+ computeStageStatuses,
+ normalizeBuckets,
+ type StageId,
+} from "./lib/stageStatus";
import {
deleteChannelAction,
updateChannelAction,
@@ -89,6 +95,99 @@ export default async function ChannelDetailPage({
const transcodeApplies =
config.handling === "transcribe" && !!config.audioFormat;
+ const stageOrder: StageId[] = [
+ "configure",
+ "playlist",
+ "download",
+ ...(transcodeApplies ? (["transcode"] as const) : []),
+ "transcribe",
+ "cleanup",
+ "diagnostics",
+ "danger",
+ ];
+
+ const panels: Partial<Record<StageId, ReactNode>> = {
+ configure: (
+ <ChannelFormClient
+ action={
+ update as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ initial={{ slug, config }}
+ submitLabel="Save changes"
+ />
+ ),
+ playlist: (
+ <PlaylistStage
+ slug={slug}
+ hasUrl={!!config.url}
+ defaultQueueKey={platformDefaultQueueKey}
+ existingQueues={existingQueues}
+ />
+ ),
+ download: (
+ <DownloadStage
+ slug={slug}
+ hasUrl={!!config.url}
+ defaultQueueKey={platformDefaultQueueKey}
+ existingQueues={existingQueues}
+ undownloadedIds={undownloadedIds}
+ noTranscriptIds={buckets.noTranscript}
+ />
+ ),
+ transcribe: (
+ <TranscribeStage
+ slug={slug}
+ existingQueues={existingQueues}
+ failedVideoIds={failedVideoIds}
+ downloadedNoTranscriptIds={buckets.downloadedNoTranscript}
+ defaultConcurrency={paths.parallelTranscribeLimit}
+ />
+ ),
+ cleanup: (
+ <CleanupStage
+ slug={slug}
+ existingQueues={existingQueues}
+ multipleAudioFormatIds={buckets.multipleAudioFormats}
+ transcodeApplies={transcodeApplies}
+ />
+ ),
+ diagnostics: (
+ <DiagnosticsStage
+ slug={slug}
+ untranscribableIds={buckets.untranscribable}
+ noMetadataIds={buckets.noMetadata}
+ missingFromArchiveIds={buckets.missingFromArchive}
+ duplicateDirIds={buckets.duplicateDirs}
+ totals={snapshot.totals}
+ availability={availability}
+ />
+ ),
+ danger: (
+ <DeleteChannelForm
+ slug={slug}
+ action={
+ del as (
+ prev: ActionResult,
+ formData: FormData,
+ ) => Promise<ActionResult>
+ }
+ />
+ ),
+ };
+ if (transcodeApplies) {
+ panels.transcode = (
+ <TranscodeStage
+ slug={slug}
+ existingQueues={existingQueues}
+ failedTranscodingIds={failedTranscodingIds}
+ untranscodedIds={buckets.untranscoded}
+ />
+ );
+ }
+
return (
<div className="flex flex-col gap-6">
<StatusHeader
@@ -100,153 +199,36 @@ export default async function ChannelDetailPage({
transcodeApplies={transcodeApplies}
/>
- <PipelineStageCard
- id="configure"
- title="Configure"
- summary={stages.configure.summary}
- defaultOpen={stages.configure.defaultOpen}
- tone={stages.configure.tone}
- >
- <ChannelFormClient
- action={
- update as (
- prev: ActionResult,
- formData: FormData,
- ) => Promise<ActionResult>
- }
- initial={{ slug, config }}
- submitLabel="Save changes"
- />
- </PipelineStageCard>
-
- <PipelineStageCard
- id="playlist"
- title="Playlist"
- summary={stages.playlist.summary}
- defaultOpen={stages.playlist.defaultOpen}
- tone={stages.playlist.tone}
- running={stages.playlist.running}
- >
- <PlaylistStage
- slug={slug}
- hasUrl={!!config.url}
- defaultQueueKey={platformDefaultQueueKey}
- existingQueues={existingQueues}
- />
- </PipelineStageCard>
-
- <PipelineStageCard
- id="download"
- title="Download"
- summary={stages.download.summary}
- defaultOpen={stages.download.defaultOpen}
- tone={stages.download.tone}
- pending={stages.download.pending}
- running={stages.download.running}
- >
- <DownloadStage
- slug={slug}
- hasUrl={!!config.url}
- defaultQueueKey={platformDefaultQueueKey}
- existingQueues={existingQueues}
- undownloadedIds={undownloadedIds}
- noTranscriptIds={buckets.noTranscript}
- />
- </PipelineStageCard>
-
- {transcodeApplies && (
- <PipelineStageCard
- id="transcode"
- title="Transcode"
- summary={stages.transcode.summary}
- defaultOpen={stages.transcode.defaultOpen}
- tone={stages.transcode.tone}
- pending={stages.transcode.pending}
- failed={stages.transcode.failed}
- running={stages.transcode.running}
- >
- <TranscodeStage
- slug={slug}
- existingQueues={existingQueues}
- failedTranscodingIds={failedTranscodingIds}
- untranscodedIds={buckets.untranscoded}
- />
- </PipelineStageCard>
- )}
-
- <PipelineStageCard
- id="transcribe"
- title="Transcribe"
- summary={stages.transcribe.summary}
- defaultOpen={stages.transcribe.defaultOpen}
- tone={stages.transcribe.tone}
- pending={stages.transcribe.pending}
- failed={stages.transcribe.failed}
- running={stages.transcribe.running}
- >
- <TranscribeStage
- slug={slug}
- existingQueues={existingQueues}
- failedVideoIds={failedVideoIds}
- downloadedNoTranscriptIds={buckets.downloadedNoTranscript}
- defaultConcurrency={paths.parallelTranscribeLimit}
- />
- </PipelineStageCard>
-
- <PipelineStageCard
- id="cleanup"
- title="Cleanup"
- summary={stages.cleanup.summary}
- defaultOpen={stages.cleanup.defaultOpen}
- tone={stages.cleanup.tone}
- pending={stages.cleanup.pending}
- running={stages.cleanup.running}
- >
- <CleanupStage
- slug={slug}
- existingQueues={existingQueues}
- multipleAudioFormatIds={buckets.multipleAudioFormats}
- transcodeApplies={transcodeApplies}
- />
- </PipelineStageCard>
-
- <PipelineStageCard
- id="diagnostics"
- title="Diagnostics"
- summary={stages.diagnostics.summary}
- defaultOpen={stages.diagnostics.defaultOpen}
- tone={stages.diagnostics.tone}
- pending={stages.diagnostics.pending}
- running={stages.diagnostics.running}
- >
- <DiagnosticsStage
- slug={slug}
- untranscribableIds={buckets.untranscribable}
- noMetadataIds={buckets.noMetadata}
- missingFromArchiveIds={buckets.missingFromArchive}
- duplicateDirIds={buckets.duplicateDirs}
- totals={snapshot.totals}
- availability={availability}
- />
- </PipelineStageCard>
+ {/* Mobile / tablet: stacked details cards */}
+ <div className="flex flex-col gap-4 lg:hidden">
+ {stageOrder.map((id) => {
+ const s = stages[id];
+ return (
+ <PipelineStageCard
+ key={id}
+ id={id}
+ title={s.title}
+ summary={s.summary}
+ defaultOpen={s.defaultOpen}
+ tone={s.tone}
+ pending={s.pending}
+ failed={s.failed}
+ running={s.running}
+ >
+ {panels[id]}
+ </PipelineStageCard>
+ );
+ })}
+ </div>
- <PipelineStageCard
- id="danger"
- title="Danger zone"
- summary={stages.danger.summary}
- defaultOpen={stages.danger.defaultOpen}
- tone={stages.danger.tone}
- >
- <DeleteChannelForm
- slug={slug}
- action={
- del as (
- prev: ActionResult,
- formData: FormData,
- ) => Promise<ActionResult>
- }
+ {/* Desktop (lg+): master/detail with hash-driven selection */}
+ <div className="hidden lg:block">
+ <StageMasterDetail
+ stages={stages}
+ stageOrder={stageOrder}
+ panels={panels}
/>
- </PipelineStageCard>
+ </div>
</div>
);
}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -26,8 +26,8 @@ export default async function ChannelsPage() {
No channels yet.
</p>
) : (
- <div className="overflow-x-auto -mx-4 md:mx-0">
- <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full md:w-auto">
+ <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full">
<thead className="bg-zinc-100 dark:bg-zinc-900">
<tr>
<Th>Slug</Th>
diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx
@@ -52,8 +52,8 @@ export default async function JobsPage() {
No jobs have run yet.
</p>
) : (
- <div className="overflow-x-auto -mx-4 md:mx-0">
- <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full md:w-auto">
+ <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full">
<thead className="bg-zinc-100 dark:bg-zinc-900">
<tr>
<th className="text-left font-medium px-3 py-2">ID</th>
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -57,7 +57,7 @@ export default async function RootLayout({
))}
</nav>
</aside>
- <main className="flex-1 px-4 md:px-6 py-4 md:py-6 max-w-5xl">{children}</main>
+ <main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main>
</body>
</html>
);
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -59,8 +59,8 @@ export default async function Dashboard() {
{channels.length > 0 && (
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Channels</h2>
- <div className="overflow-x-auto -mx-4 md:mx-0">
- <table className="text-sm border-y md:border md:border-zinc-200 dark:md:border-zinc-800 border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full md:w-auto">
+ <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible">
+ <table className="text-sm border-y md:border md:border-zinc-200 dark:md:border-zinc-800 border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full">
<thead className="bg-zinc-100 dark:bg-zinc-900">
<tr>
<th className="text-left font-medium px-3 py-2">Slug</th>