commit 247f0e280c6be3fe23706a02b128e82ea0f6ef31
parent 71dbb91b579312e8df32a2b0f235bdf69e4dc58c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 11 May 2026 21:06:17 -0400
fix layout flip
Diffstat:
3 files changed, 206 insertions(+), 111 deletions(-)
diff --git a/editor/app/channels/[slug]/components/StageMasterDetail.tsx b/editor/app/channels/[slug]/components/StageMasterDetail.tsx
@@ -1,79 +0,0 @@
-"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/StagesView.tsx b/editor/app/channels/[slug]/components/StagesView.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from "react";
+import type { StageId, StageStatus, StageTone } from "../lib/stageStatus";
+import { StageRailItem } from "./StageRailItem";
+
+const TONE_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",
+};
+
+const TONE_BADGE: Record<StageTone, string> = {
+ neutral:
+ "bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-700",
+ attention:
+ "bg-amber-50 dark:bg-amber-950 text-amber-800 dark:text-amber-200 border-amber-200 dark:border-amber-800",
+ danger:
+ "bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200 border-red-200 dark:border-red-800",
+ running:
+ "bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800",
+ ok: "bg-emerald-50 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-200 border-emerald-200 dark:border-emerald-800",
+};
+
+const LG_QUERY = "(min-width: 1024px)";
+
+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 "";
+}
+
+type Props = {
+ stages: Record<StageId, StageStatus>;
+ stageOrder: StageId[];
+ panels: Partial<Record<StageId, ReactNode>>;
+};
+
+export function StagesView({ 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 [openIds, setOpenIds] = useState<Set<StageId>>(
+ () => new Set(stageOrder.filter((id) => stages[id].defaultOpen)),
+ );
+
+ // Mobile: when a hash anchor (e.g. a StatusHeader badge) navigates to a
+ // stage, also expand it so the section is not just scrolled into view as a
+ // collapsed header.
+ useEffect(() => {
+ if (!fromHash) return;
+ setOpenIds((prev) => {
+ if (prev.has(fromHash)) return prev;
+ const next = new Set(prev);
+ next.add(fromHash);
+ return next;
+ });
+ }, [fromHash]);
+
+ const toggle = useCallback((id: StageId) => {
+ // On lg+ the section header isn't a toggle (content is always shown when
+ // the section is the selected one). Bail so a click doesn't desync the
+ // mobile open-state behind the user's back.
+ if (
+ typeof window !== "undefined" &&
+ window.matchMedia(LG_QUERY).matches
+ ) {
+ return;
+ }
+ setOpenIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ return (
+ <div className="lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-6">
+ <nav
+ aria-label="Pipeline stages"
+ className="hidden lg:flex lg:flex-col lg:gap-0.5 lg:sticky lg:top-32 lg:self-start"
+ >
+ {stageOrder.map((id) => (
+ <StageRailItem
+ key={id}
+ status={stages[id]}
+ active={id === selected}
+ href={`#stage-${id}`}
+ />
+ ))}
+ </nav>
+ <div className="flex flex-col gap-4 lg:gap-0 min-w-0">
+ {stageOrder.map((id) => {
+ const status = stages[id];
+ const isOpen = openIds.has(id);
+ const isSelected = id === selected;
+ const isDanger = id === "danger";
+ const contentId = `stage-${id}-content`;
+ return (
+ <section
+ key={id}
+ id={`stage-${id}`}
+ aria-label={`${status.title} stage`}
+ className={`rounded-md border ${TONE_BORDER[status.tone]} bg-white dark:bg-zinc-900 scroll-mt-32 ${isSelected ? "" : "lg:hidden"}`}
+ >
+ <button
+ type="button"
+ onClick={() => toggle(id)}
+ aria-expanded={isOpen}
+ aria-controls={contentId}
+ aria-label={`${status.title} stage summary`}
+ className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left rounded-t-md hover:bg-zinc-50 dark:hover:bg-zinc-800/50 lg:hover:bg-transparent lg:dark:hover:bg-transparent lg:cursor-default"
+ >
+ <div className="flex items-baseline gap-3 min-w-0">
+ <h2
+ className={`text-lg font-semibold ${isDanger ? "text-red-700 dark:text-red-400" : ""}`}
+ >
+ {status.title}
+ </h2>
+ <span className="text-sm text-zinc-500 truncate">
+ {status.summary}
+ </span>
+ </div>
+ <div className="flex items-center gap-2 flex-shrink-0">
+ {status.running && (
+ <span
+ className={`text-xs uppercase tracking-wide rounded-full border px-2 py-0.5 ${TONE_BADGE.running}`}
+ aria-label={`${status.title} running`}
+ >
+ running
+ </span>
+ )}
+ {status.pending > 0 && (
+ <span
+ className={`text-xs rounded-full border px-2 py-0.5 ${TONE_BADGE.attention}`}
+ aria-label={`${status.title} pending count`}
+ >
+ {status.pending}
+ </span>
+ )}
+ {status.failed > 0 && (
+ <span
+ className={`text-xs rounded-full border px-2 py-0.5 ${TONE_BADGE.danger}`}
+ aria-label={`${status.title} failed count`}
+ >
+ {status.failed} failed
+ </span>
+ )}
+ <Chevron isOpen={isOpen} />
+ </div>
+ </button>
+ <div
+ id={contentId}
+ className={`px-4 pb-4 pt-1 lg:py-4 flex-col gap-4 border-t border-zinc-100 dark:border-zinc-800 ${isOpen ? "flex" : "hidden"} lg:flex`}
+ >
+ {panels[id]}
+ </div>
+ </section>
+ );
+ })}
+ </div>
+ </div>
+ );
+}
+
+function Chevron({ isOpen }: { isOpen: boolean }) {
+ return (
+ <svg
+ aria-hidden="true"
+ className={`w-4 h-4 text-zinc-400 transition-transform lg:hidden ${isOpen ? "rotate-90" : ""}`}
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ >
+ <path
+ fillRule="evenodd"
+ d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 0 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
+}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -17,8 +17,7 @@ import {
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 { StagesView } from "./components/StagesView";
import { StatusHeader } from "./components/StatusHeader";
import { CleanupStage } from "./components/stages/CleanupStage";
import { DiagnosticsStage } from "./components/stages/DiagnosticsStage";
@@ -199,36 +198,7 @@ export default async function ChannelDetailPage({
transcodeApplies={transcodeApplies}
/>
- {/* 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>
-
- {/* Desktop (lg+): master/detail with hash-driven selection */}
- <div className="hidden lg:block">
- <StageMasterDetail
- stages={stages}
- stageOrder={stageOrder}
- panels={panels}
- />
- </div>
+ <StagesView stages={stages} stageOrder={stageOrder} panels={panels} />
</div>
);
}