Archilyzer · Source

archilyzer

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

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:
Mcommon/components/ui/sonner.tsx | 4++++
Meditor/CHANGELOG.md | 1+
Meditor/app/components/ChangelogNavLink.tsx | 9+++++++--
Meditor/app/components/CommandPalette.tsx | 446++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Meditor/app/globals.css | 5+++--
Meditor/app/layout.tsx | 75++++++++++++++++++++-------------------------------------------------------
Aeditor/app/lib/nav.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/page.tsx | 59+++++++++++++++++++++++++++++++++++++----------------------
Meditor/e2e/export-search.spec.ts | 22+++++++++++++++-------
Meditor/e2e/site-scope.spec.ts | 15++++++++++-----
Meditor/package.json | 1+
Mpnpm-lock.yaml | 3+++
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)