commit cd54c6f2179e53e9e4d22bfc0b902de439828eb9
parent a687f2f8aba96d283461d05d88e6cfca2166e3f1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 13:14:23 -0400
redesign Phase 3: editor command-first cockpit (cmdk palette, shared nav, live dashboard, Sonner)
Turn the editor shell into a compact, command-first cockpit on the shared design
system. (StreamActionLog migration + per-page internals stay for Phase 4.)
Command palette (editor/app/components/CommandPalette.tsx) — rebuilt on shadcn
`Command` (cmdk) + Dialog, replacing the hand-rolled, nav-only overlay:
- Navigates to every route AND runs global server actions inline: Sync all
channels, Refresh all reports, Detect duplicate shorts, Retry all failed
jobs, Drain the queue, Pause/Resume all workers. Each fires the action,
reports the result via a Sonner toast, and router.refresh()es. Contextual
channel-status filters when on /channels/[slug]. ⌘K/Ctrl-K toggles.
Shared nav (editor/app/lib/nav.ts) — NAV_GROUPS (with lucide icons + palette
keywords) is now one source consumed by BOTH the sidebar and the palette; the
old palette hard-coded a stale 5-route subset that drifted from the sidebar.
Sonner — Toaster mounted in the layout (inside ThemeProvider); `toast` is
re-exported from common `ui/sonner` so the editor calls it through the shared
package with no direct `sonner` dep.
Shell + dashboard — layout sidebar/badges and the dashboard restyled compact
onto the design tokens + Source type family (icons in nav, brass count pills).
The Dashboard gains a live **Live jobs** panel (reuses ActiveJobsLive polling
/api/jobs/active) for the channel → download → monitor hot path. e2e hooks
preserved: H1 names, role="group"+aria-label stat cards, sidebar link
names/hrefs, badge aria-labels, the Active-site/?site= contract.
Fix (server→client boundary): the server layout can't pass a lucide *component*
to the client ChangelogNavLink — pass a rendered icon element instead. Editor
gains a direct lucide-react dep (icons used in editor app code).
Test fixes surfaced by the full editor e2e run:
- site-scope.spec deploy test: main's deploy rework moved "Build static
export" into a collapsed Individual-steps details, so assert the visible
primary "Build & deploy" path (was asserting the old top-level layout).
- editor/e2e/export-search.spec.ts (exercises the Phase 2 export search, not
run during Phase 2): footer SVG now carries intrinsic width/height (the FOUC
fix); the Search CTA dirty signal is `data-dirty`, not a bg-blue-600 class;
and the Archive-density result rows need a taller viewport for the 4th
window-virtualized row to materialize.
Gate: common + editor typecheck; editor production build; full editor e2e —
306 passed; the remaining failures were confirmed pre-existing on the merge base
(the "queues a job" family, sync-break, cut-release) or documented-flaky
(actionable, widget:22 dev Dev-Tools button), none caused by Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
12 files changed, 419 insertions(+), 301 deletions(-)
diff --git a/common/components/ui/sonner.tsx b/common/components/ui/sonner.tsx
@@ -40,4 +40,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
)
}
+// Re-export the imperative API so consumers (e.g. the editor) call toasts
+// through the shared package and don't each need a direct `sonner` dependency.
+export { toast } from "sonner"
+
export { Toaster }
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Command-first cockpit: a ⌘K command palette that runs actions, plus a live dashboard.** Press **⌘K / Ctrl-K** anywhere to open a fast command palette (rebuilt on cmdk) that jumps to any page **and runs pool/site actions inline** — Sync all channels, Refresh all reports, Detect duplicate shorts, Retry all failed jobs, Drain the queue, Pause/Resume all workers — each firing the server action and reporting the result as a **toast** (Sonner). On a channel page it also offers the channel's status filters. The sidebar and palette now read from one shared nav source (`editor/app/lib/nav.ts`) so they never drift (the old palette listed a stale 5-route subset), and every nav item gains an icon. The **Dashboard** gains a **Live jobs** panel that polls running jobs in real time (reusing the active-jobs monitor), and the whole editor shell is restyled compact onto the shared design tokens/kit with the Source type family. See `editor/app/{layout.tsx,page.tsx,lib/nav.ts,components/{CommandPalette.tsx,ChangelogNavLink.tsx}}` and `common/components/ui/sonner.tsx`.
- **New "Family hub URL" setting.** Settings gains an optional hub URL (e.g. `https://archilyzer.com`); every export site renders a link back to it in the header/footer, tying the family of public sites together. Leave it blank for no hub link. Normalized to an absolute http(s) URL on save (`settings.homepageUrl`; see `common/lib/settings.ts`, `editor/app/settings/{components/SettingsForm.tsx,actions.ts}`). Part of the family-wide UI redesign that also restyles the public export sites.
- **Source-truncated downloads are now caught at download time, and the yt-dlp download format is selectable (Odysee defaults to `original`).** A download that completed (yt-dlp exit 0) but whose audio is far shorter than the video — e.g. an Odysee/LBRY video whose every HLS rung is CDN-truncated to a few minutes while the full audio lives only in the multi-GB `original` format — used to pass the audio-check (the short file is structurally *clean*) and get transcribed, so only the **post-transcription** coverage detector caught it. Two changes fix this. **(1) A download-time duration guard:** after each managed download the produced `audio.<fmt>` is probed with **ffprobe** (new `common/ytdlp/ffprobeDuration.ts`, `paths.ffprobeBin` / `FFPROBE_BIN`) and compared to the metadata duration using the same thresholds as the transcript-coverage detector (`isShortAudio` in `common/lib/transcriptCoverage.ts`: non-livestream, ≥10min, <50% covered). A large shortfall is recorded as a new terminal **`failed-short-audio`** status with the measured `{audioDurationSec, expectedDurationSec, coverage}` — caught **before** transcription (the inline no-subs-fallback whisper is pre-checked too). The short stub is **kept on disk** (so it isn't silently re-downloaded into a loop) and is **not** classified for platform backoff (a truncated stream isn't a transient error). Surfaced as a new **`shortAudio`** snapshot bucket (mirroring `corrupt-full-source`: excluded from `downloadedNoTranscript` so it's never auto-transcribed, and from the wrong-format cleanup so the kept file isn't deleted) → a **`short_audio`** channel-list filter + **Select short-audio** quick-select + bulk **Re-download truncated** action, a **"Download was truncated at the source"** banner on the video page with a one-click **Re-download as Original**, a **"Channels with truncated downloads (short audio)"** `/actionable` section, and a **"Truncated download — audio far shorter than the video (file kept)"** download badge. The auto-download runner lands a `failed-short-audio` unit as a failed job. **(2) Selectable, platform-aware download format:** the previously-hardcoded `-f bestaudio/worst` is now a preset (`auto` | `original` | `bestaudio` | `bestvideo_audio`; `common/ytdlp/downloadFormat.ts`) resolved with the same override chain as `audioFormat` — per-download (the redownload **Format** picker) > per-channel (`ChannelConfig.downloadFormat`, a select in the channel form) > global (`SiteSettings.downloadFormat`, a select in **Settings**). **`auto` is platform-aware:** Odysee/LBRY resolves to `original/bestaudio/worst` (so the truncation is avoided at the source); everything else stays `bestaudio/worst`. The source platform is taken from the prefetched metadata extractor. The short-audio bucket's re-download reuses the per-video fixer (delete the stub → re-fetch with the per-source default → re-transcribe) via the existing `redownload-incomplete-bucket` job (now also driving `ReplayBucket: "shortAudio"`). See `common/ytdlp/{downloadFormat.ts,ffprobeDuration.ts,downloadOneManaged.ts,runYtdlp.ts}`, `common/lib/{transcriptCoverage.ts,downloadOutcome.ts,settings.ts,channelConfig.ts,paths.ts,transcripts-server.ts}`, `common/controller/{channelSnapshot.ts,autoRunner.ts}`, `common/jobs/jobSpec.ts`, `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,lib/{fixIncompleteTranscript.ts,videoRows.ts,videoRowsServer.ts,stageStatus.ts},components/VideoListPane.tsx,videos/[id]/{videoActions.ts,components/VideoPanel.tsx}}`, `editor/app/{settings/{actions.ts,components/SettingsForm.tsx},channels/components/{ChannelForm.tsx,parseChannelForm.ts},actionable/{lib/loadActionable.ts,page.tsx,components/InlineActionButton.tsx},jobs/jobReplayRegistry.ts}`, and `editor/e2e/download-format-guard.spec.ts`.
- **A complete-but-corrupt audio download is no longer re-downloaded forever — it's kept and flagged instead.** When an audio-checked download finished (yt-dlp exit 0, all bytes) but its final integrity probe came back `malformed`, the orchestrator rolled the whole file back and re-downloaded it — repeatedly. For a fast download that completes inside one checkpoint interval no `.good` baseline ever exists, so each "rollback" discarded the entire file and re-fetched it from scratch (a 2.46 GB Odysee video looped until the rollback cap, leaving no audio behind), and a cancel landing mid-loop was deferred behind the next full re-download. Now the final-probe path is bounded: a malformed final probe triggers **exactly one** re-download; if it's still malformed, the orchestrator **keeps the downloaded container on disk** (for inspection) and records a new terminal **`corrupt-full-source`** status — re-downloading a complete file can't change a deterministic verdict. This is distinct from `failed-corrupt-source` (a download that never finished, driven by the checkpoint rollback cap). Surfaced as a new **`corruptFullSource`** channel-snapshot bucket → a Download-stage summary line ("corrupt full source (file kept)"), an **amber download-dot** + `corrupt_full_source` row status in the per-channel video list (folded into the *No audio* filter), and a **"Corrupt full source — download completed but audio is malformed (file kept)"** badge on the video page. It's terminal and **not** retried (auto-runner treats it as skipped; the kept file is an artifact, so it isn't re-queued) and **not** counted as a failed transcription or a transcode-pending video. Cancellation is also fixed: a pending abort now wins over an in-flight rollback decision, and the loop re-checks the abort signal after the final probe so Cancel stops it promptly. See `common/ytdlp/audioCheckedDownload.ts`, `common/ytdlp/downloadOneManaged.ts`, `common/lib/downloadOutcome.ts`, `common/controller/{autoRunner.ts,channelSnapshot.ts}`, `common/ytdlp/runYtdlp.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/components/VideoPanel.tsx}`, and `editor/e2e/audio-check-scenarios.spec.ts`.
diff --git a/editor/app/components/ChangelogNavLink.tsx b/editor/app/components/ChangelogNavLink.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
const STORAGE_KEY = "changelogLastSeen";
@@ -10,11 +10,15 @@ export function ChangelogNavLink({
label,
latestDate,
className,
+ icon,
}: {
href: string;
label: string;
latestDate: string | null;
className?: string;
+ // A pre-rendered icon ELEMENT (not the component) — a server layout can't pass
+ // a function/component across the client boundary, but a ReactNode is fine.
+ icon?: ReactNode;
}) {
const [unread, setUnread] = useState(false);
@@ -30,12 +34,13 @@ export function ChangelogNavLink({
return (
<Link href={href} className={className}>
+ {icon}
<span>{label}</span>
{unread && (
<span
aria-label="New changelog entry"
title="New changelog entry"
- className="ml-auto inline-block w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400"
+ className="ml-auto inline-block w-2 h-2 rounded-full bg-brand"
/>
)}
</Link>
diff --git a/editor/app/components/CommandPalette.tsx b/editor/app/components/CommandPalette.tsx
@@ -1,247 +1,277 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
+import {
+ Activity,
+ ListChecks,
+ PauseOctagon,
+ Play,
+ RefreshCw,
+ RotateCcw,
+ Copy as CopyIcon,
+ type LucideIcon,
+} from "lucide-react";
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "yt-dlp-transcript-common/components/ui/command";
+import { toast } from "yt-dlp-transcript-common/components/ui/sonner";
+import { NAV_GROUPS } from "../lib/nav";
+import { syncAllChannelsAction } from "../channels/actions";
+import {
+ refreshAllChannelSnapshotsAction,
+ runDuplicateDetectionAction,
+} from "../actionable/actions";
+import { retryAllFailedAction, drainAllAction } from "../jobs/actions";
+import {
+ pauseAllWorkersAction,
+ resumeAllWorkersAction,
+} from "../workers/actions";
-type Command = {
+// The editor's command-first spine. Rebuilt on cmdk (shadcn Command) over the
+// shared NAV_GROUPS (so it never drifts from the sidebar) plus a set of global
+// "run a thing" actions that fire a server action, toast the result, and refresh.
+// Long per-channel streaming jobs still live on their pages (Phase 4); the
+// palette runs the quick, no-argument pool/site actions you'd otherwise hunt for.
+
+type RunAction = {
id: string;
label: string;
- hint?: string;
- run: () => void;
+ icon: LucideIcon;
+ keywords?: string;
+ confirm?: string;
+ // Resolve to a success toast message; throw to surface an error toast.
+ run: () => Promise<string>;
};
+const GLOBAL_ACTIONS: RunAction[] = [
+ {
+ id: "act-sync-all",
+ label: "Sync all channels",
+ icon: RefreshCw,
+ keywords: "fetch new videos refresh metadata",
+ run: async () => {
+ const r = await syncAllChannelsAction();
+ const n = r.queued.length;
+ const skip = r.skipped.length ? ` · ${r.skipped.length} skipped` : "";
+ return `Queued ${n} channel sync${n === 1 ? "" : "s"}${skip}`;
+ },
+ },
+ {
+ id: "act-refresh-reports",
+ label: "Refresh all channel reports",
+ icon: RefreshCw,
+ keywords: "snapshot recompute stats",
+ run: async () => {
+ const r = await refreshAllChannelSnapshotsAction();
+ const n = r.queued.length;
+ const skip = r.skipped.length ? ` · ${r.skipped.length} skipped` : "";
+ return `Refreshing ${n} report${n === 1 ? "" : "s"}${skip}`;
+ },
+ },
+ {
+ id: "act-detect-duplicates",
+ label: "Detect duplicate shorts",
+ icon: CopyIcon,
+ keywords: "find reposts cross-platform",
+ run: async () => {
+ const r = await runDuplicateDetectionAction("shorts");
+ if (!r.ok) throw new Error(r.error);
+ return `Found ${r.clusters} duplicate cluster${r.clusters === 1 ? "" : "s"} (${r.videosInClusters} videos)`;
+ },
+ },
+ {
+ id: "act-retry-failed",
+ label: "Retry all failed jobs",
+ icon: RotateCcw,
+ keywords: "requeue errors",
+ run: async () => {
+ const r = await retryAllFailedAction();
+ return `Retried ${r.count} failed job${r.count === 1 ? "" : "s"}`;
+ },
+ },
+ {
+ id: "act-drain-all",
+ label: "Drain all queued jobs",
+ icon: ListChecks,
+ keywords: "cancel clear queue",
+ confirm: "Drain every queued job? Running jobs are left alone.",
+ run: async () => {
+ const r = await drainAllAction();
+ return `Drained ${r.count} job${r.count === 1 ? "" : "s"}`;
+ },
+ },
+ {
+ id: "act-pause-workers",
+ label: "Pause all workers",
+ icon: PauseOctagon,
+ keywords: "stop transcription halt",
+ run: async () => {
+ const r = await pauseAllWorkersAction();
+ if (!r.ok) throw new Error(r.error ?? "Failed to pause workers");
+ return "Paused all workers";
+ },
+ },
+ {
+ id: "act-resume-workers",
+ label: "Resume all workers",
+ icon: Play,
+ keywords: "start transcription continue",
+ run: async () => {
+ const r = await resumeAllWorkersAction();
+ if (!r.ok) throw new Error(r.error ?? "Failed to resume workers");
+ return "Resumed all workers";
+ },
+ },
+];
+
function channelSlugFrom(pathname: string): string | null {
const m = /^\/channels\/([^/?#]+)/.exec(pathname);
return m ? decodeURIComponent(m[1]) : null;
}
-function videoIdFrom(
- pathname: string,
- search: ReturnType<typeof useSearchParams>,
-): string | null {
- const m = /^\/channels\/[^/]+\/videos\/([^/?#]+)/.exec(pathname);
- if (m) return decodeURIComponent(m[1]);
- const fromQuery = search.get("video");
- return fromQuery && fromQuery.length > 0 ? fromQuery : null;
-}
+const CHANNEL_FILTERS: { filter: string; label: string }[] = [
+ { filter: "needs_action", label: "Needs action" },
+ { filter: "failed", label: "Failed" },
+ { filter: "no_audio", label: "No audio" },
+ { filter: "downloaded_no_transcript", label: "No transcript" },
+ { filter: "untranscribable", label: "Untranscribable" },
+ { filter: "running", label: "Running" },
+ { filter: "all", label: "All (clear filter)" },
+];
export function CommandPalette() {
const router = useRouter();
const pathname = usePathname() ?? "/";
const search = useSearchParams();
const [open, setOpen] = useState(false);
- const [query, setQuery] = useState("");
- const [highlight, setHighlight] = useState(0);
- const inputRef = useRef<HTMLInputElement | null>(null);
-
const slug = channelSlugFrom(pathname);
- const videoId = videoIdFrom(pathname, search);
-
- const close = useCallback(() => {
- setOpen(false);
- setQuery("");
- setHighlight(0);
- }, []);
-
- const commands = useMemo<Command[]>(() => {
- const out: Command[] = [
- {
- id: "nav-dashboard",
- label: "Dashboard",
- hint: "navigation",
- run: () => router.push("/"),
- },
- {
- id: "nav-channels",
- label: "Channels",
- hint: "navigation",
- run: () => router.push("/channels"),
- },
- {
- id: "nav-jobs",
- label: "Jobs",
- hint: "navigation",
- run: () => router.push("/jobs"),
- },
- {
- id: "nav-build",
- label: "Build",
- hint: "navigation",
- run: () => router.push("/build"),
- },
- {
- id: "nav-settings",
- label: "Settings",
- hint: "navigation",
- run: () => router.push("/settings"),
- },
- ];
-
- if (slug) {
- const setFilter = (filter: string, label: string) => ({
- id: `filter-${filter}`,
- label,
- hint: `channel: ${slug}`,
- run: () => {
- const params = new URLSearchParams(search.toString());
- if (filter === "all") params.delete("filter");
- else params.set("filter", filter);
- params.delete("video");
- const q = params.toString();
- router.push(q ? `/channels/${slug}?${q}` : `/channels/${slug}`);
- },
- });
- out.push(
- setFilter("needs_action", "Filter: Needs action"),
- setFilter("failed", "Filter: Failed"),
- setFilter("no_audio", "Filter: No audio"),
- setFilter("downloaded_no_transcript", "Filter: No transcript"),
- setFilter("untranscribable", "Filter: Untranscribable"),
- setFilter("running", "Filter: Running"),
- setFilter("all", "Filter: All (clear)"),
- {
- id: "channel-pipeline",
- label: "Open channel pipeline panel",
- hint: `channel: ${slug}`,
- run: () => router.push(`/channels/${slug}`),
- },
- );
- }
-
- if (slug && videoId) {
- out.push({
- id: "deselect-video",
- label: "Close video panel",
- hint: `video: ${videoId}`,
- run: () => {
- const params = new URLSearchParams(search.toString());
- params.delete("video");
- const q = params.toString();
- router.push(q ? `/channels/${slug}?${q}` : `/channels/${slug}`);
- },
- });
- }
-
- return out;
- }, [router, search, slug, videoId]);
-
- const filtered = useMemo(() => {
- const q = query.trim().toLowerCase();
- if (!q) return commands;
- return commands.filter(
- (c) =>
- c.label.toLowerCase().includes(q) ||
- (c.hint?.toLowerCase().includes(q) ?? false),
- );
- }, [commands, query]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
- const isOpen = e.key === "k" && (e.metaKey || e.ctrlKey);
- if (isOpen) {
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
- return;
- }
- if (e.key === "Escape" && open) {
- e.preventDefault();
- close();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [open, close]);
+ }, []);
- useEffect(() => {
- if (open) {
- setHighlight(0);
- const t = setTimeout(() => inputRef.current?.focus(), 0);
- return () => clearTimeout(t);
- }
- }, [open]);
+ const go = useCallback(
+ (href: string) => {
+ setOpen(false);
+ router.push(href);
+ },
+ [router],
+ );
- useEffect(() => {
- if (highlight >= filtered.length) setHighlight(Math.max(0, filtered.length - 1));
- }, [filtered.length, highlight]);
+ const runAction = useCallback(
+ (action: RunAction) => {
+ setOpen(false);
+ if (action.confirm && !window.confirm(action.confirm)) return;
+ const toastId = toast.loading(`${action.label}…`);
+ void (async () => {
+ try {
+ const message = await action.run();
+ toast.success(message, { id: toastId });
+ router.refresh();
+ } catch (err) {
+ toast.error(`${action.label} failed`, {
+ id: toastId,
+ description: err instanceof Error ? err.message : String(err),
+ });
+ }
+ })();
+ },
+ [router],
+ );
- if (!open) return null;
+ const setChannelFilter = useCallback(
+ (filter: string) => {
+ if (!slug) return;
+ setOpen(false);
+ const params = new URLSearchParams(search.toString());
+ if (filter === "all") params.delete("filter");
+ else params.set("filter", filter);
+ params.delete("video");
+ const q = params.toString();
+ router.push(q ? `/channels/${slug}?${q}` : `/channels/${slug}`);
+ },
+ [router, search, slug],
+ );
return (
- <div
- role="dialog"
- aria-label="command palette"
- aria-modal="true"
- className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[10vh] bg-zinc-900/40 dark:bg-zinc-950/60"
- onMouseDown={(e) => {
- if (e.target === e.currentTarget) close();
- }}
+ <CommandDialog
+ open={open}
+ onOpenChange={setOpen}
+ title="Command palette"
+ description="Jump to a page or run an action"
>
- <div className="w-full max-w-lg rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 shadow-lg overflow-hidden">
- <input
- ref={inputRef}
- type="text"
- value={query}
- onChange={(e) => setQuery(e.target.value)}
- placeholder="Type a command…"
- aria-label="command input"
- className="w-full px-3 py-2.5 text-sm bg-transparent border-b border-zinc-200 dark:border-zinc-800 outline-none"
- onKeyDown={(e) => {
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setHighlight((h) => Math.min(h + 1, filtered.length - 1));
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setHighlight((h) => Math.max(h - 1, 0));
- } else if (e.key === "Enter") {
- e.preventDefault();
- const cmd = filtered[highlight];
- if (cmd) {
- close();
- cmd.run();
- }
- }
- }}
- />
- <ul role="listbox" aria-label="commands" className="max-h-80 overflow-y-auto">
- {filtered.length === 0 ? (
- <li className="px-3 py-4 text-sm text-zinc-500">No matches.</li>
- ) : (
- filtered.map((c, i) => {
- const active = i === highlight;
+ <CommandInput placeholder="Search pages and actions…" aria-label="command input" />
+ <CommandList>
+ <CommandEmpty>No matches.</CommandEmpty>
+
+ {slug && (
+ <>
+ <CommandGroup heading={`Channel: ${slug}`}>
+ {CHANNEL_FILTERS.map((f) => (
+ <CommandItem
+ key={f.filter}
+ value={`filter ${f.label} ${slug}`}
+ onSelect={() => setChannelFilter(f.filter)}
+ >
+ <Activity className="text-muted-foreground" />
+ <span>Filter: {f.label}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ <CommandSeparator />
+ </>
+ )}
+
+ <CommandGroup heading="Actions">
+ {GLOBAL_ACTIONS.map((a) => {
+ const Icon = a.icon;
+ return (
+ <CommandItem
+ key={a.id}
+ value={`${a.label} ${a.keywords ?? ""}`}
+ onSelect={() => runAction(a)}
+ >
+ <Icon className="text-muted-foreground" />
+ <span>{a.label}</span>
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+
+ <CommandSeparator />
+
+ {NAV_GROUPS.map((group) => (
+ <CommandGroup key={group.label} heading={group.label}>
+ {group.links.map((link) => {
+ const Icon = link.icon;
return (
- <li key={c.id}>
- <button
- type="button"
- onMouseEnter={() => setHighlight(i)}
- onClick={() => {
- close();
- c.run();
- }}
- aria-selected={active}
- role="option"
- className={`w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 ${
- active
- ? "bg-blue-50 dark:bg-blue-950/40"
- : "hover:bg-zinc-50 dark:hover:bg-zinc-800/40"
- }`}
- >
- <span className="flex-1 truncate">{c.label}</span>
- {c.hint && (
- <span className="text-xs text-zinc-500 truncate flex-shrink-0">
- {c.hint}
- </span>
- )}
- </button>
- </li>
+ <CommandItem
+ key={link.href}
+ value={`${link.label} ${link.keywords ?? ""} ${group.label}`}
+ onSelect={() => go(link.href)}
+ >
+ <Icon className="text-muted-foreground" />
+ <span>{link.label}</span>
+ </CommandItem>
);
- })
- )}
- </ul>
- <div className="border-t border-zinc-200 dark:border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-500 flex items-center justify-between">
- <span>↵ to run · ↑↓ to move · Esc to close</span>
- <span>⌘K</span>
- </div>
- </div>
- </div>
+ })}
+ </CommandGroup>
+ ))}
+ </CommandList>
+ </CommandDialog>
);
}
diff --git a/editor/app/globals.css b/editor/app/globals.css
@@ -3,8 +3,9 @@
@source "../../common/components";
/* Design tokens, the `dark` variant, and theme palettes live in
- common/styles/tokens.css. The editor stays on the neutral "base" family in
- Phase 0 (visual parity); the command-first cockpit restyle lands later. */
+ common/styles/tokens.css. The editor runs the neutral "base" family as a
+ compact, command-first cockpit (shell + dashboard + palette on the shared
+ kit); per-page internals migrate incrementally. */
body {
background: var(--background);
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -16,6 +16,8 @@ import { AutoRefresh } from "./components/AutoRefresh";
import { CommandPalette } from "./components/CommandPalette";
import { ChangelogNavLink } from "./components/ChangelogNavLink";
import { SiteScopeSelect } from "./components/SiteScopeSelect";
+import { Toaster } from "yt-dlp-transcript-common/components/ui/sonner";
+import { NAV_GROUPS, type NavLink } from "./lib/nav";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
@@ -29,53 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
-type NavLink = {
- href: string;
- label: string;
- badgeKey?: "jobs" | "running" | "changelog";
-};
-
-type NavGroup = {
- // Short label shown above the group. The active site selector scopes the
- // "Site" group; "Pool" and "Manage" are unaffected (shared across all sites).
- label: string;
- links: NavLink[];
-};
-
-const NAV_GROUPS: NavGroup[] = [
- {
- label: "Site",
- links: [
- { href: "/", label: "Dashboard" },
- { href: "/channels", label: "Channels" },
- { href: "/charts", label: "Charts" },
- { href: "/deploy", label: "Deploy" },
- ],
- },
- {
- label: "Pool",
- links: [
- { href: "/jobs", label: "Jobs", badgeKey: "jobs" },
- { href: "/jobs/active", label: "Active", badgeKey: "running" },
- { href: "/workers", label: "Workers" },
- { href: "/widget/builder", label: "Monitor" },
- { href: "/scheduler", label: "Schedule" },
- { href: "/auto-queue", label: "Auto-queue" },
- { href: "/build", label: "Build" },
- { href: "/actionable", label: "Actionable" },
- { href: "/saved-videos", label: "Saved videos" },
- ],
- },
- {
- label: "Manage",
- links: [
- { href: "/sites", label: "Sites" },
- { href: "/homepage", label: "Homepage" },
- { href: "/settings", label: "Settings" },
- { href: "/changelog", label: "Changelog", badgeKey: "changelog" },
- ],
- },
-];
+// NAV_GROUPS now lives in ./lib/nav (shared with the command palette).
function readChangelogLatestDate(): string | null {
try {
@@ -107,8 +63,9 @@ export default async function RootLayout({
siteTitle: s.siteTitle,
}));
const navItemClass =
- "px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap flex items-center gap-2";
+ "px-2.5 py-1.5 rounded-md text-foreground/80 hover:text-foreground hover:bg-muted whitespace-nowrap flex items-center gap-2 transition-colors";
const renderLink = (link: NavLink) => {
+ const Icon = link.icon;
if (link.badgeKey === "changelog") {
return (
<ChangelogNavLink
@@ -117,6 +74,12 @@ export default async function RootLayout({
label={link.label}
latestDate={latestChangelogDate}
className={navItemClass}
+ icon={
+ <Icon
+ className="size-4 shrink-0 text-muted-foreground"
+ aria-hidden="true"
+ />
+ }
/>
);
}
@@ -133,11 +96,12 @@ export default async function RootLayout({
badgeCount > 0;
return (
<Link key={link.href} href={link.href} className={navItemClass}>
+ <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span>{link.label}</span>
{showBadge && (
<span
aria-label={`${badgeCount} ${badgeNoun}${badgeCount === 1 ? "" : "s"}`}
- className="ml-auto text-xs rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-2 py-0.5 leading-none"
+ className="ml-auto text-xs rounded-full border border-brand/30 bg-brand-soft text-brand px-2 py-0.5 leading-none"
>
{badgeCount}
</span>
@@ -151,21 +115,21 @@ export default async function RootLayout({
suppressHydrationWarning
className={`${fontVars} h-full antialiased`}
>
- <body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
+ <body className="min-h-full flex flex-col md:flex-row bg-background text-foreground">
<ThemeScript />
<ThemeProvider>
<AppFrame
sidebar={
- <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col">
- <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800 flex items-start justify-between gap-2">
+ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-border bg-card flex flex-col">
+ <div className="px-3 py-2.5 md:py-3 md:border-b md:border-border flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href="/"
- className="font-semibold tracking-tight block leading-tight"
+ className="font-display text-base font-semibold tracking-tight block leading-tight"
>
{headerLabel}
</Link>
- <div className="text-xs uppercase tracking-wide text-zinc-500 mt-0.5">
+ <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground mt-0.5">
editor
</div>
</div>
@@ -180,7 +144,7 @@ export default async function RootLayout({
key={group.label}
className="flex flex-row md:flex-col gap-1 md:gap-0.5 items-center md:items-stretch"
>
- <div className="px-3 py-1 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-600 whitespace-nowrap">
+ <div className="px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground/70 whitespace-nowrap">
{group.label}
</div>
{group.links.map(renderLink)}
@@ -194,6 +158,7 @@ export default async function RootLayout({
<Suspense fallback={null}>
<CommandPalette />
</Suspense>
+ <Toaster position="bottom-right" />
<AutoRefresh
intervalMs={settings.autoRefreshIntervalSeconds * 1000}
/>
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -0,0 +1,80 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ Activity,
+ CalendarClock,
+ ChartColumnBig,
+ Cpu,
+ Globe,
+ Hammer,
+ House,
+ LayoutDashboard,
+ ListChecks,
+ ListPlus,
+ Monitor,
+ Rocket,
+ ScrollText,
+ Settings,
+ TriangleAlert,
+ Tv,
+ Bookmark,
+} from "lucide-react";
+
+// Single source of truth for the editor's primary navigation. Consumed by BOTH
+// the sidebar (editor/app/layout.tsx) and the command palette
+// (editor/app/components/CommandPalette.tsx) so they never drift — the old
+// palette hard-coded a stale 5-item subset. Icons are used by the palette and
+// the compact sidebar; `badgeKey` drives the live count pills / changelog dot.
+
+export type NavBadgeKey = "jobs" | "running" | "changelog";
+
+export type NavLink = {
+ href: string;
+ label: string;
+ icon: LucideIcon;
+ badgeKey?: NavBadgeKey;
+ // Extra search terms for the palette (not shown), e.g. synonyms.
+ keywords?: string;
+};
+
+export type NavGroup = {
+ label: string;
+ links: NavLink[];
+};
+
+export const NAV_GROUPS: NavGroup[] = [
+ {
+ label: "Site",
+ links: [
+ { href: "/", label: "Dashboard", icon: LayoutDashboard, keywords: "home overview" },
+ { href: "/channels", label: "Channels", icon: Tv },
+ { href: "/charts", label: "Charts", icon: ChartColumnBig, keywords: "stats graphs" },
+ { href: "/deploy", label: "Deploy", icon: Rocket, keywords: "publish release" },
+ ],
+ },
+ {
+ label: "Pool",
+ links: [
+ { href: "/jobs", label: "Jobs", icon: ListChecks, badgeKey: "jobs", keywords: "queue tasks" },
+ { href: "/jobs/active", label: "Active", icon: Activity, badgeKey: "running", keywords: "monitor running live" },
+ { href: "/workers", label: "Workers", icon: Cpu, keywords: "transcription whisper gpu" },
+ { href: "/widget/builder", label: "Monitor", icon: Monitor, keywords: "widget embed" },
+ { href: "/scheduler", label: "Schedule", icon: CalendarClock, keywords: "sync cron cadence" },
+ { href: "/auto-queue", label: "Auto-queue", icon: ListPlus, keywords: "auto runner policy" },
+ { href: "/build", label: "Build", icon: Hammer, keywords: "static export" },
+ { href: "/actionable", label: "Actionable", icon: TriangleAlert, keywords: "needs attention todo" },
+ { href: "/saved-videos", label: "Saved videos", icon: Bookmark, keywords: "backup" },
+ ],
+ },
+ {
+ label: "Manage",
+ links: [
+ { href: "/sites", label: "Sites", icon: Globe },
+ { href: "/homepage", label: "Homepage", icon: House, keywords: "hub" },
+ { href: "/settings", label: "Settings", icon: Settings, keywords: "config preferences" },
+ { href: "/changelog", label: "Changelog", icon: ScrollText, badgeKey: "changelog", keywords: "release notes" },
+ ],
+ },
+];
+
+// Flat list of every nav link (palette convenience).
+export const NAV_LINKS: NavLink[] = NAV_GROUPS.flatMap((g) => g.links);
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -14,6 +14,8 @@ import {
} from "yt-dlp-transcript-common/lib/site";
import { loadActionableSummary } from "./actionable/lib/loadActionable";
import { resolveActiveSite } from "./lib/activeSite";
+import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs";
+import { ActiveJobsLive } from "./jobs/components/ActiveJobsLive";
import type { ActionableRow } from "./actionable/lib/loadActionable";
export const dynamic = "force-dynamic";
@@ -76,13 +78,16 @@ export default async function Dashboard({
const runningJobs = getRegistry()
.list()
.filter((j) => j.status === "running" || j.status === "queued");
+ const activeJobsPayload = await buildActiveJobsPayload();
const changelogSource = loadChangelog();
const needsAttention =
undownloaded.length > 0 || untranscribed.length > 0;
return (
<div className="flex flex-col gap-6">
- <h1 className="text-2xl font-semibold">Dashboard</h1>
+ <h1 className="font-display text-2xl font-semibold tracking-tight">
+ Dashboard
+ </h1>
<dl className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<Stat label="channels" value={String(channels.length)} />
<Stat label="videos" value={totalVideos.toLocaleString()} />
@@ -97,13 +102,20 @@ export default async function Dashboard({
/>
</dl>
+ {runningJobs.length > 0 && (
+ <section aria-label="Live jobs" className="flex flex-col gap-3">
+ <h2 className="font-display text-lg font-semibold">Live jobs</h2>
+ <ActiveJobsLive initial={activeJobsPayload} />
+ </section>
+ )}
+
{needsAttention && (
<Link
href="/actionable"
aria-label="needs attention"
- className="block rounded-md border border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/40 px-4 py-3 hover:border-amber-400 dark:hover:border-amber-700"
+ className="block rounded-md border border-amber-300/70 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/40 px-4 py-3 hover:border-amber-400 dark:hover:border-amber-700 transition-colors"
>
- <h2 className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-400 mb-1">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-amber-700 dark:text-amber-400 mb-1">
Needs attention
</h2>
<p className="text-sm">
@@ -127,16 +139,16 @@ export default async function Dashboard({
<Link
href="/changelog"
aria-label="View full changelog"
- className="block rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3 hover:border-zinc-400 dark:hover:border-zinc-600"
+ className="block rounded-md border border-border bg-card px-4 py-3 hover:border-brand/40 transition-colors"
>
- <h2 className="text-xs uppercase tracking-wide text-zinc-500 mb-1">
+ <h2 className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">
Recent changes
</h2>
<div className="relative max-h-48 overflow-hidden">
<Changelog source={changelogSource} limit={1} />
<div
aria-hidden="true"
- className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white dark:from-zinc-900 to-transparent"
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-card to-transparent"
/>
</div>
</Link>
@@ -144,10 +156,10 @@ export default async function Dashboard({
{channels.length > 0 && (
<section className="flex flex-col gap-3">
- <h2 className="text-lg font-semibold">Channels</h2>
+ <h2 className="font-display text-lg font-semibold">Channels</h2>
<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">
+ <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>
@@ -159,21 +171,20 @@ export default async function Dashboard({
</thead>
<tbody>
{channels.map((c) => (
- <tr
- key={c.slug}
- className="border-t border-zinc-200 dark:border-zinc-800"
- >
+ <tr key={c.slug} className="border-t border-border">
<td className="px-3 py-2 font-mono">
<Link
href={`/channels/${c.slug}`}
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ className="underline underline-offset-2 hover:text-brand transition-colors"
>
{c.slug}
</Link>
</td>
<td className="px-3 py-2">{c.config.handling}</td>
- <td className="px-3 py-2 text-right">{c.videoCount}</td>
- <td className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap">
+ <td className="px-3 py-2 text-right tabular-nums">
+ {c.videoCount}
+ </td>
+ <td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">
{c.config.lastSyncedAt
? new Date(c.config.lastSyncedAt).toLocaleString()
: "never"}
@@ -194,12 +205,14 @@ function Stat({ label, value }: { label: string; value: string }) {
<div
role="group"
aria-label={label}
- className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
+ className="rounded-md border border-border bg-card px-4 py-3"
>
- <div className="text-xs uppercase tracking-wide text-zinc-500">
+ <div className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
{label}
</div>
- <div className="text-2xl font-semibold mt-1">{value}</div>
+ <div className="font-display text-2xl font-semibold mt-1 tabular-nums">
+ {value}
+ </div>
</div>
);
}
@@ -217,12 +230,14 @@ function StatLink({
<Link
href={href}
aria-label={label}
- className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3 hover:border-zinc-400 dark:hover:border-zinc-600"
+ className="rounded-md border border-border bg-card px-4 py-3 hover:border-brand/40 transition-colors"
>
- <div className="text-xs uppercase tracking-wide text-zinc-500">
+ <div className="font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
{label}
</div>
- <div className="text-2xl font-semibold mt-1">{value}</div>
+ <div className="font-display text-2xl font-semibold mt-1 tabular-nums">
+ {value}
+ </div>
</Link>
);
}
diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts
@@ -412,6 +412,10 @@ test.describe("export TranscriptSearch — query builder + filter layout", () =>
test("unlisted videos are shown by default and 'Unlisted' unchecked hides them", async ({
page,
}) => {
+ // Give the window-virtualized results enough height to materialize all four
+ // matched rows (the default 720px viewport only fits three Archive-density
+ // cards, leaving the last one un-rendered and unscrollable).
+ await page.setViewportSize({ width: 1280, height: 1600 });
await search(page, "platypus");
await expect(
resultCard(page, /Hidden mammal documentary.*Test Channel/),
@@ -587,8 +591,10 @@ test.describe("export footer", () => {
await expect(social).toHaveAttribute("target", "_blank");
const svgHtml = await social.innerHTML();
expect(svgHtml).toContain("<svg");
- expect(svgHtml).not.toMatch(/<svg[^>]*\swidth=/);
- expect(svgHtml).not.toMatch(/<svg[^>]*\sheight=/);
+ // The footer sizes social SVGs with intrinsic width/height attributes so
+ // they don't balloon before CSS loads on a static host (the FOUC fix).
+ expect(svgHtml).toMatch(/<svg[^>]*\swidth=/);
+ expect(svgHtml).toMatch(/<svg[^>]*\sheight=/);
});
test("a site's own social links win over the global default", async ({
@@ -666,15 +672,17 @@ test.describe("export TranscriptSearch — deferred search submit", () => {
await search(page, "platypus");
const searchBtn = page.getByTestId("search-submit");
await expect(searchBtn).toBeVisible();
- // Pristine after commit: outline/muted styling — no blue background.
- await expect(searchBtn).not.toHaveClass(/bg-blue-600/);
+ // Pristine after commit: muted/outline variant (data-dirty="false"). The
+ // dirty signal is a stable data attribute, not a color class, since the kit
+ // Button drives the primary style.
+ await expect(searchBtn).toHaveAttribute("data-dirty", "false");
await page.getByRole("checkbox", { name: "Deleted" }).uncheck();
- // Dirty: primary blue styling.
- await expect(searchBtn).toHaveClass(/bg-blue-600/);
+ // Dirty: primary CTA.
+ await expect(searchBtn).toHaveAttribute("data-dirty", "true");
// Submit, returns to pristine.
await searchBtn.click();
- await expect(searchBtn).not.toHaveClass(/bg-blue-600/);
+ await expect(searchBtn).toHaveAttribute("data-dirty", "false");
});
});
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -69,18 +69,23 @@ test("deploy targets the active site and is disabled under all sites", async ({
}) => {
await twoSites();
await page.goto("/deploy?site=__all__");
+ // Under "all sites" the primary Build & deploy path is blocked: its notice is
+ // shown and the button is disabled. (The "Build static export" step now lives
+ // in a collapsed "Individual steps" details, so it's asserted via the primary
+ // visible path here.)
await expect(
- page.getByText(/select a specific site from the sidebar to build/i),
+ page.getByText(/select a specific site from the sidebar to build & deploy\./i),
).toBeVisible();
await expect(
- page.getByRole("button", { name: "Build static export" }),
+ page.getByRole("button", { name: "Build & deploy" }),
).toBeDisabled();
await page.goto("/deploy?site=alpha");
- await expect(page.getByText(/Building/).first()).toBeVisible();
+ // Selecting a specific site clears the "pick a site" notice (build/deploy is
+ // now scoped to alpha).
await expect(
- page.getByRole("button", { name: "Build static export" }),
- ).toBeEnabled();
+ page.getByText(/select a specific site from the sidebar to build & deploy\./i),
+ ).toHaveCount(0);
});
test("pool views show every site regardless of the selector", async ({
diff --git a/editor/package.json b/editor/package.json
@@ -15,6 +15,7 @@
},
"dependencies": {
"@sindresorhus/slugify": "^3.0.0",
+ "lucide-react": "^1.16.0",
"markdown-to-jsx": "^7.7.4",
"next": "16.2.3",
"react": "19.2.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -108,6 +108,9 @@ importers:
'@sindresorhus/slugify':
specifier: ^3.0.0
version: 3.0.0
+ lucide-react:
+ specifier: ^1.16.0
+ version: 1.16.0(react@19.2.4)
markdown-to-jsx:
specifier: ^7.7.4
version: 7.7.17(react@19.2.4)