Archilyzer · Source

archilyzer

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

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:
Deditor/app/channels/[slug]/components/StageMasterDetail.tsx | 79-------------------------------------------------------------------------------
Aeditor/app/channels/[slug]/components/StagesView.tsx | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 34++--------------------------------
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> ); }