diff --git a/apps/cms/schema.graphql b/apps/cms/schema.graphql index be5b5a028..01e064595 100644 --- a/apps/cms/schema.graphql +++ b/apps/cms/schema.graphql @@ -1348,8 +1348,8 @@ enum ENUM_COMPONENTENRICHMENTJOBSTEP_NAME { embeddings metadata mux_upload - theology_validation_bible_quotes seo_improvements + theology_validation_bible_quotes transcription translation } @@ -4010,4 +4010,4 @@ input WatchSettingInput { type WatchSettingRelationResponseCollection { nodes: [WatchSetting!]! -} +} \ No newline at end of file diff --git a/apps/manager/components.json b/apps/manager/components.json new file mode 100644 index 000000000..8d886db57 --- /dev/null +++ b/apps/manager/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/manager/next.config.ts b/apps/manager/next.config.ts index 23ce34142..3c8aea9a0 100644 --- a/apps/manager/next.config.ts +++ b/apps/manager/next.config.ts @@ -5,6 +5,14 @@ const nextConfig: NextConfig = { experimental: { typedRoutes: true, }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "images.unsplash.com", + }, + ], + }, } export default nextConfig diff --git a/apps/manager/package.json b/apps/manager/package.json index 1d52439be..b31ac1221 100644 --- a/apps/manager/package.json +++ b/apps/manager/package.json @@ -19,21 +19,29 @@ "@forge/video-player": "workspace:*", "@mux/mux-node": "^9.0.0", "@t3-oss/env-nextjs": "^0.13.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "lucide-react": "^0.577.0", "next": "^16.1.6", "openai": "^4.0.0", "p-limit": "^7.3.0", "react": "19.2.4", + "react-blurhash": "^0.3.0", "react-dom": "19.2.4", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", "video.js": "^8.22.0", "workflow": "^4.2.0-beta.70", "zod": "^4.3.6" }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.3", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "eslint": "^9.0.0", "eslint-config-next": "^16.1.6", + "postcss": "^8.5.10", + "tailwindcss": "^4.2.3", "typescript": "^5", "vitest": "2.1.9" } diff --git a/apps/manager/postcss.config.mjs b/apps/manager/postcss.config.mjs new file mode 100644 index 000000000..2f8795a93 --- /dev/null +++ b/apps/manager/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +} + +export default config diff --git a/apps/manager/src/app/api/coverage-snapshots/cache.ts b/apps/manager/src/app/api/coverage-snapshots/cache.ts new file mode 100644 index 000000000..ec82d8fad --- /dev/null +++ b/apps/manager/src/app/api/coverage-snapshots/cache.ts @@ -0,0 +1,67 @@ +import { graphql } from "@forge/graphql" +import getClient from "@/cms/client" +import { createSwrCache } from "@/lib/swr-cache" + +const GET_COVERAGE_SNAPSHOTS = graphql(` + query GetCoverageSnapshots( + $filters: CoverageSnapshotFiltersInput + $sort: [String] + $pagination: PaginationArg + ) { + coverageSnapshots(filters: $filters, sort: $sort, pagination: $pagination) { + documentId + date + computedAt + totalVideos + videosWithAiMetadata + videosWithHumanMetadata + subtitlesHumanTotal + subtitlesAiTotal + audioHumanTotal + audioAiTotal + languageCoverage + } + } +`) + +export const coverageSnapshotRangeDateRegex = /^\d{4}-\d{2}-\d{2}$/ + +type LatestCoverageSnapshotResult = { + snapshot: Awaited> +} + +async function fetchLatestSnapshotFromCms() { + const client = getClient() + const result = await client.query({ + query: GET_COVERAGE_SNAPSHOTS, + variables: { + sort: ["date:desc"], + pagination: { limit: 1 }, + }, + fetchPolicy: "no-cache", + }) + + return result.data?.coverageSnapshots?.[0] ?? null +} + +async function fetchLatestSnapshot(): Promise { + try { + const snapshot = await fetchLatestSnapshotFromCms() + return { snapshot } + } catch (error) { + console.warn( + "[coverage-snapshot-cache] Falling back to empty latest snapshot:", + error instanceof Error ? error.message : "Unknown error", + ) + return { snapshot: null } + } +} + +export const latestCoverageSnapshotCache = createSwrCache({ + fetcher: fetchLatestSnapshot, + ttlMs: 5 * 60_000, + maxStaleMs: 24 * 60 * 60_000, + label: "coverage-snapshot-cache", +}) + +export { GET_COVERAGE_SNAPSHOTS } diff --git a/apps/manager/src/app/api/coverage-snapshots/route.ts b/apps/manager/src/app/api/coverage-snapshots/route.ts index 6e2841ea5..026d646b1 100644 --- a/apps/manager/src/app/api/coverage-snapshots/route.ts +++ b/apps/manager/src/app/api/coverage-snapshots/route.ts @@ -1,58 +1,20 @@ import { NextResponse } from "next/server" import { z } from "zod" -import { graphql } from "@forge/graphql" import { authenticateRequest } from "@/lib/auth" import getClient from "@/cms/client" -import { createSwrCache } from "@/lib/swr-cache" - -const GET_COVERAGE_SNAPSHOTS = graphql(` - query GetCoverageSnapshots( - $filters: CoverageSnapshotFiltersInput - $sort: [String] - $pagination: PaginationArg - ) { - coverageSnapshots(filters: $filters, sort: $sort, pagination: $pagination) { - documentId - date - computedAt - totalVideos - videosWithAiMetadata - videosWithHumanMetadata - subtitlesHumanTotal - subtitlesAiTotal - audioHumanTotal - audioAiTotal - languageCoverage - } - } -`) - -const dateRegex = /^\d{4}-\d{2}-\d{2}$/ +import { + coverageSnapshotRangeDateRegex, + GET_COVERAGE_SNAPSHOTS, + latestCoverageSnapshotCache, +} from "./cache" const rangeSchema = z.object({ - startDate: z.string().regex(dateRegex, "Must be YYYY-MM-DD format"), - endDate: z.string().regex(dateRegex, "Must be YYYY-MM-DD format"), -}) - -async function fetchLatestSnapshot() { - const client = getClient() - const result = await client.query({ - query: GET_COVERAGE_SNAPSHOTS, - variables: { - sort: ["date:desc"], - pagination: { limit: 1 }, - }, - fetchPolicy: "no-cache", - }) - - return result.data?.coverageSnapshots?.[0] ?? null -} - -export const latestCoverageSnapshotCache = createSwrCache({ - fetcher: fetchLatestSnapshot, - ttlMs: 5 * 60_000, - maxStaleMs: 24 * 60 * 60_000, - label: "coverage-snapshot-cache", + startDate: z + .string() + .regex(coverageSnapshotRangeDateRegex, "Must be YYYY-MM-DD format"), + endDate: z + .string() + .regex(coverageSnapshotRangeDateRegex, "Must be YYYY-MM-DD format"), }) export async function GET(request: Request) { @@ -66,7 +28,7 @@ export async function GET(request: Request) { const client = getClient() if (isLatest) { - const snapshot = await latestCoverageSnapshotCache.get() + const { snapshot } = await latestCoverageSnapshotCache.get() return NextResponse.json({ snapshot }) } diff --git a/apps/manager/src/app/api/languages/cache.ts b/apps/manager/src/app/api/languages/cache.ts new file mode 100644 index 000000000..975de855a --- /dev/null +++ b/apps/manager/src/app/api/languages/cache.ts @@ -0,0 +1,40 @@ +import { env } from "@/config/env" +import { createSwrCache } from "@/lib/swr-cache" + +type CmsLanguageGeo = { + continents: Array<{ id: string; name: string }> + countries: Array<{ id: string; name: string; continentId: string }> + languages: Array<{ + id: string + englishLabel: string + nativeLabel: string + countryIds: string[] + continentIds: string[] + countrySpeakers: Record + }> +} + +async function fetchLanguageGeo(): Promise { + const url = `${env.STRAPI_URL}/api/language-geo` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + throw new Error( + `CMS /api/language-geo returned ${response.status}: ${await response.text()}`, + ) + } + + const data = (await response.json()) as CmsLanguageGeo + return JSON.stringify(data) +} + +export const languageCache = createSwrCache({ + fetcher: fetchLanguageGeo, + ttlMs: 24 * 60 * 60_000, + maxStaleMs: 48 * 60 * 60_000, + label: "language-cache", +}) diff --git a/apps/manager/src/app/api/languages/route.ts b/apps/manager/src/app/api/languages/route.ts index 5adff19c6..3d20af4e8 100644 --- a/apps/manager/src/app/api/languages/route.ts +++ b/apps/manager/src/app/api/languages/route.ts @@ -1,59 +1,6 @@ import { NextResponse } from "next/server" import { authenticateRequest } from "@/lib/auth" -import { env } from "@/config/env" -import { createSwrCache } from "@/lib/swr-cache" - -// --------------------------------------------------------------------------- -// Types from CMS /api/language-geo endpoint -// --------------------------------------------------------------------------- - -// Must match LanguageGeoResult in apps/cms/src/api/language-geo/services/language-geo.ts -type CmsLanguageGeo = { - continents: Array<{ id: string; name: string }> - countries: Array<{ id: string; name: string; continentId: string }> - languages: Array<{ - id: string - englishLabel: string - nativeLabel: string - countryIds: string[] - continentIds: string[] - countrySpeakers: Record - }> -} - -// --------------------------------------------------------------------------- -// Fetch from CMS language-geo endpoint -// --------------------------------------------------------------------------- - -async function fetchLanguageGeo(): Promise { - const url = `${env.STRAPI_URL}/api/language-geo` - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, - signal: AbortSignal.timeout(10_000), - }) - - if (!response.ok) { - throw new Error( - `CMS /api/language-geo returned ${response.status}: ${await response.text()}`, - ) - } - - const data = (await response.json()) as CmsLanguageGeo - return JSON.stringify(data) -} - -// --------------------------------------------------------------------------- -// SWR cache (geo data changes only on core sync) -// Caches pre-serialized JSON string for zero-cost response serving. -// --------------------------------------------------------------------------- - -export const languageCache = createSwrCache({ - fetcher: fetchLanguageGeo, - ttlMs: 24 * 60 * 60_000, // 24 hours — geo data changes only on core sync - maxStaleMs: 48 * 60 * 60_000, // 48 hours — hard limit - label: "language-cache", -}) +import { languageCache } from "./cache" // --------------------------------------------------------------------------- // Route handler diff --git a/apps/manager/src/app/api/videos/cache.ts b/apps/manager/src/app/api/videos/cache.ts new file mode 100644 index 000000000..89b3ba790 --- /dev/null +++ b/apps/manager/src/app/api/videos/cache.ts @@ -0,0 +1,85 @@ +import { env } from "@/config/env" +import { createSwrCache } from "@/lib/swr-cache" + +export type CmsVideoCoverage = { + documentId: string + coreId: string | null + title: string | null + label: string | null + slug: string | null + aiMetadata: boolean | null + imageUrl: string | null + parentDocumentIds: string[] + coverage: { + subtitles: { human: number; ai: number } + audio: { human: number; ai: number } + } +} + +async function fetchVideoCoverage( + languageIds?: string[], +): Promise { + const params = new URLSearchParams() + if (languageIds && languageIds.length > 0) { + params.set("languageIds", languageIds.join(",")) + } + + const qs = params.toString() + const url = `${env.STRAPI_URL}/api/video-coverage${qs ? `?${qs}` : ""}` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + throw new Error( + `CMS /api/video-coverage returned ${response.status}: ${await response.text()}`, + ) + } + + const data = (await response.json()) as { videos: CmsVideoCoverage[] } + return data.videos +} + +export function normalizeCoverageLanguageIds(languageIds: string[]): string[] { + return Array.from( + new Set(languageIds.map((languageId) => languageId.trim()).filter(Boolean)), + ).sort((left, right) => left.localeCompare(right)) +} + +export function getFilteredVideoCoverageCacheKey( + languageIds: string[], +): string { + return normalizeCoverageLanguageIds(languageIds).join(",") +} + +export const videoCache = createSwrCache({ + fetcher: () => fetchVideoCoverage(), + ttlMs: 2 * 60_000, + maxStaleMs: 30 * 60_000, + label: "video-cache", +}) + +const filteredVideoCaches = new Map< + string, + ReturnType> +>() + +export function getFilteredVideoCoverageCache(languageIds: string[]) { + const normalizedLanguageIds = normalizeCoverageLanguageIds(languageIds) + const cacheKey = getFilteredVideoCoverageCacheKey(normalizedLanguageIds) + const existing = filteredVideoCaches.get(cacheKey) + if (existing) { + return existing + } + + const cache = createSwrCache({ + fetcher: () => fetchVideoCoverage(normalizedLanguageIds), + ttlMs: 2 * 60_000, + maxStaleMs: 30 * 60_000, + label: `video-cache:${cacheKey}`, + }) + filteredVideoCaches.set(cacheKey, cache) + return cache +} diff --git a/apps/manager/src/app/api/videos/route.test.ts b/apps/manager/src/app/api/videos/route.test.ts index eee8436ce..579e90f3c 100644 --- a/apps/manager/src/app/api/videos/route.test.ts +++ b/apps/manager/src/app/api/videos/route.test.ts @@ -3,7 +3,7 @@ import { getFilteredVideoCoverageCache, getFilteredVideoCoverageCacheKey, normalizeCoverageLanguageIds, -} from "@/app/api/videos/route" +} from "@/app/api/videos/cache" describe("/api/videos coverage cache helpers", () => { it("normalizes language ids into a stable sorted unique set", () => { diff --git a/apps/manager/src/app/api/videos/route.ts b/apps/manager/src/app/api/videos/route.ts index 52647cb28..83bd219f5 100644 --- a/apps/manager/src/app/api/videos/route.ts +++ b/apps/manager/src/app/api/videos/route.ts @@ -1,22 +1,11 @@ import { NextResponse } from "next/server" import { authenticateRequest } from "@/lib/auth" -import { env } from "@/config/env" -import { createSwrCache } from "@/lib/swr-cache" - -type CmsVideoCoverage = { - documentId: string - coreId: string | null - title: string | null - label: string | null - slug: string | null - aiMetadata: boolean | null - imageUrl: string | null - parentDocumentIds: string[] - coverage: { - subtitles: { human: number; ai: number } - audio: { human: number; ai: number } - } -} +import { + type CmsVideoCoverage, + getFilteredVideoCoverageCache, + normalizeCoverageLanguageIds, + videoCache, +} from "./cache" type CoverageCounts = { human: number; ai: number; none: number } @@ -32,74 +21,6 @@ const LABEL_DISPLAY: Record = { unknown: "Other", } -async function fetchVideoCoverage( - languageIds?: string[], -): Promise { - const params = new URLSearchParams() - if (languageIds && languageIds.length > 0) { - params.set("languageIds", languageIds.join(",")) - } - - const qs = params.toString() - const url = `${env.STRAPI_URL}/api/video-coverage${qs ? `?${qs}` : ""}` - - const response = await fetch(url, { - headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, - signal: AbortSignal.timeout(10_000), - }) - - if (!response.ok) { - throw new Error( - `CMS /api/video-coverage returned ${response.status}: ${await response.text()}`, - ) - } - - const data = (await response.json()) as { videos: CmsVideoCoverage[] } - return data.videos -} - -export function normalizeCoverageLanguageIds(languageIds: string[]): string[] { - return Array.from( - new Set(languageIds.map((languageId) => languageId.trim()).filter(Boolean)), - ).sort((left, right) => left.localeCompare(right)) -} - -export function getFilteredVideoCoverageCacheKey( - languageIds: string[], -): string { - return normalizeCoverageLanguageIds(languageIds).join(",") -} - -export const videoCache = createSwrCache({ - fetcher: () => fetchVideoCoverage(), - ttlMs: 2 * 60_000, - maxStaleMs: 30 * 60_000, - label: "video-cache", -}) - -const filteredVideoCaches = new Map< - string, - ReturnType> ->() - -export function getFilteredVideoCoverageCache(languageIds: string[]) { - const normalizedLanguageIds = normalizeCoverageLanguageIds(languageIds) - const cacheKey = getFilteredVideoCoverageCacheKey(normalizedLanguageIds) - const existing = filteredVideoCaches.get(cacheKey) - if (existing) { - return existing - } - - const cache = createSwrCache({ - fetcher: () => fetchVideoCoverage(normalizedLanguageIds), - ttlMs: 2 * 60_000, - maxStaleMs: 30 * 60_000, - label: `video-cache:${cacheKey}`, - }) - filteredVideoCaches.set(cacheKey, cache) - return cache -} - export async function GET(request: Request) { const authError = await authenticateRequest(request) if (authError) return authError diff --git a/apps/manager/src/app/dashboard/agents/page.tsx b/apps/manager/src/app/dashboard/agents/page.tsx index 756ab4a7f..dd4a21cd3 100644 --- a/apps/manager/src/app/dashboard/agents/page.tsx +++ b/apps/manager/src/app/dashboard/agents/page.tsx @@ -50,9 +50,11 @@ export default async function AgentsDashboardPage() { } return ( - +
+ +
) } diff --git a/apps/manager/src/app/dashboard/coverage/page.tsx b/apps/manager/src/app/dashboard/coverage/page.tsx index 85ba55a44..aaf19f8f6 100644 --- a/apps/manager/src/app/dashboard/coverage/page.tsx +++ b/apps/manager/src/app/dashboard/coverage/page.tsx @@ -8,7 +8,7 @@ import { export const dynamic = "force-dynamic" export const metadata: Metadata = { - title: "Coverage -- Forge Manager", + title: "Coverage -- Studio", } export default async function CoveragePage({ @@ -20,11 +20,13 @@ export default async function CoveragePage({ const requestedLanguageIds = resolveRequestedLanguageIds(resolvedSearchParams) return ( - +
+ +
) } diff --git a/apps/manager/src/app/dashboard/design-system/page.tsx b/apps/manager/src/app/dashboard/design-system/page.tsx new file mode 100644 index 000000000..fbca6101e --- /dev/null +++ b/apps/manager/src/app/dashboard/design-system/page.tsx @@ -0,0 +1,1024 @@ +import type { Metadata } from "next" +import Image from "next/image" +import type { LucideIcon } from "lucide-react" +import type { ReactNode } from "react" +import { + BarChart2, + Bot, + Captions, + Check, + Clock3, + Languages, + ListChecks, + Mic2, + Plus, + RefreshCw, + Search, + Settings2, + Upload, + X, +} from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { + PageDescription, + PageEyebrow, + PageIntro, + PageTitle, +} from "@/components/ui/page-intro" +import { + SegmentedControl, + SegmentedControlButton, +} from "@/components/ui/segmented-control" +import { StepperDemo } from "./stepper-demo" +import { DesignSystemReportSwitcher } from "./report-switcher" + +export const metadata: Metadata = { + title: "Design System -- Studio", +} + +const sourceComponents = [ + { + name: "Manager shell", + role: "Shared Studio shell, navigation, breadcrumbs, mode switch, and profile menu.", + path: "apps/manager/src/features/shell/manager-shell.tsx", + }, + { + name: "Coverage report", + role: "Coverage intro, diagram, language selector, collection browser, and preview panel.", + path: "apps/manager/src/features/coverage/coverage-report-client.tsx", + }, + { + name: "Language selector", + role: "Geo-aware search, selected-language pills, and confirmation actions.", + path: "apps/manager/src/features/coverage/LanguageGeoSelector.tsx", + }, + { + name: "Jobs UI", + role: "Live jobs list, detail summary, workflow steps, and review surfaces.", + path: "apps/manager/src/features/jobs", + }, + { + name: "Agents UI", + role: "Automation list, create wizard, cadence controls, and run history.", + path: "apps/manager/src/features/agents", + }, + { + name: "Shared UI primitives", + role: "Buttons, badges, cards, inputs, segmented controls, modal shell, and stepper.", + path: "apps/manager/src/components/ui", + }, +] + +const componentGroups = [ + "Screens", + "Foundations", + "Navigation", + "Buttons", + "Forms", + "Badges", + "Cards", + "Tables", + "Coverage", + "Jobs", + "Review", + "Agents", + "Feedback", +] + +const productTiles: Array<{ + title: string + meta: string + icon: LucideIcon + image?: string +}> = [ + { + title: "Report", + meta: "Coverage and subtitle health", + icon: BarChart2, + image: "/World_map_with_points.svg", + }, + { + title: "Jobs", + meta: "Live enrichment workflows", + icon: ListChecks, + image: "/jesusfilm-sign.svg", + }, + { + title: "Review", + meta: "Generated metadata QA", + icon: Captions, + image: "/favicon.svg", + }, + { + title: "Agents", + meta: "Recurring automation runs", + icon: Bot, + }, +] + +function SectionHeader({ + eyebrow, + title, + children, +}: { + eyebrow: string + title: string + children: ReactNode +}) { + return ( +
+ + {eyebrow} + +

+ {title} +

+

+ {children} +

+
+ ) +} + +function DemoCard({ + title, + children, + className, +}: { + title: string + children: ReactNode + className?: string +}) { + return ( + + +

+ {title} +

+
+ {children} +
+ ) +} + +function ScreenFrame({ + icon: Icon, + title, + subtitle, + actions, + children, +}: { + icon: LucideIcon + title: string + subtitle: string + actions?: ReactNode + children: ReactNode +}) { + return ( + + +
+
+
+
+
+

+ {title} +

+

+ {subtitle} +

+
+
+ {actions ? ( +
{actions}
+ ) : null} +
+
+ {children} +
+ ) +} + +function SourceTable() { + return ( +
+
+ + + + + + + + + + {sourceComponents.map((component) => ( + + + + + + ))} + +
ComponentUseSource
+ {component.name} + + {component.role} + + + {component.path} + +
+
+
+ ) +} + +function CoverageBarDemo() { + return ( +
+
+ + + +
+
+
+ Verified + + 63% + +
+
+ AI + + 21% + +
+
+ None + + 16% + +
+
+
+ ) +} + +export default function DesignSystemPage() { + return ( +
+
+ + Studio UI + + Design system components + + + A kitchen sink for the Studio surfaces: coverage reporting, job + execution, enrichment review, and agent automations. + + + +
+ {componentGroups.map((group) => ( + + {group} + + ))} +
+ +
+ {productTiles.map((tile) => { + const Icon = tile.icon + return ( + + +
+ {tile.image ? ( + + ) : ( +
+
+

+ {tile.title} +

+

+ {tile.meta} +

+
+
+
+ ) + })} +
+
+ +
+ + The kitchen sink now points at the same primitive layer used by the + authenticated shell and the login experience. + + +
+ + + + + } + > +
+
+
+ +
+
+ {[ + [ + "Jesus Film", + "74 videos ready for subtitle review", + "46%", + ], + [ + "Stories of Hope", + "32 videos missing AI subtitles", + "28%", + ], + [ + "Walking with Jesus", + "18 videos with partial coverage", + "63%", + ], + ].map(([title, meta, percent], index) => ( +
+ +
+

+ {title} +

+

+ {meta} +

+
+ + {percent} + +
+ ))} +
+
+ +
+
+
+

+ Selected collection +

+

+ Walking with Jesus +

+
+ +
+
+ + Subtitle health + + + Language reach + + + Collection notes + +
+
+
+
+ + + + + + + } + > +
+
+
+ {[ + ["Queued", "12"], + ["Running", "4"], + ["Completed", "189"], + ].map(([label, value]) => ( +
+

+ {label} +

+

+ {value} +

+
+ ))} +
+ +
+
+ Job + Status + Languages + Retries +
+ {[ + ["Generate subtitles", "running", "es, fr", "1"], + ["Sync chapters", "pending", "en", "0"], + ["Backfill metadata", "completed", "es", "0"], + ].map(([job, status, languages, retries], index) => ( +
+ + {job} + + + {status} + + {languages} + {retries} +
+ ))} +
+
+ +
+
+

+ Step diagnostics +

+
+ {[ + ["Fetch video data", "completed", "Source asset loaded"], + [ + "Run subtitle coverage", + "running", + "Waiting on providers", + ], + ["Publish to CMS", "pending", "Queued after enrichment"], + ].map(([title, status, detail]) => ( +
+ + {status} + +
+

+ {title} +

+

+ {detail} +

+
+
+ ))} +
+
+
+
+
+ + + + + + + } + > +
+
+
+
+
+ +
+
+
+ + 1:38 / 6:00 + +
+
+
+ +
+
+

+ Review summary +

+
+ ready + chapters + titles +
+
+
+

+ Compare panels +

+
+
+
+
+
+
+
+ + + + + + + } + > +
+
+ + Active + Paused + Templates + + + {[ + ["Translate missing subtitles", "Hourly", "active"], + ["Generate missing metadata", "Daily", "active"], + ["Voice-over dubbing", "Weekly", "paused"], + ].map(([title, cadence, status]) => ( +
+ + +
+

+ {title} +

+

+ {cadence} +

+
+ + {status} + +
+ ))} +
+ +
+
+
+
+

+ New automation +

+

+ Turn repeatable enrichment work into a durable agent. +

+
+ +
+
+ +
+
+
+
+
+
+
+ +
+ + Typography, warm neutrals, and clear states stay centralized so the + production surfaces and the kitchen sink share the same language. + + +
+ +
+
+

+ Eyebrow +

+

+ Studio UI +

+
+
+

+ Title +

+

+ Design system +

+
+
+

+ Body +

+

+ Keep layout calm, reduce visual noise, and let actions feel + tactile without making the screen busy. +

+
+
+
+ + +
+ outline + neutral + running + completed + failed +
+
+ + +
+
+ Primary panel +
+
+ Muted panel +
+
+ Selected surface +
+
+
+
+
+ +
+ + Shared controls should stay compact, tactile, and easy to scan on both + mobile and desktop layouts. + + +
+ +
+ + Explore + Select + + + Active + Paused + Templates + +
+
+ + +
+ + + + + + +
+
+ + + + +
+
+ +
+ + Inputs and prompt bars keep the same shape language as the shell so + transitions between pages feel continuous. + + +
+ +
+
+ + +
+
+ + Created by + + + Video only + + + Audiobooks have a new home + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + Futuristic cityscape + + + Enchanted forest + + + Cyberpunk alley + +
+
+ {["Start frame", "End frame", "Image refs"].map((label) => ( +
+ {label} +
+ ))} +
+ +
+ Veo 3.1 Fast + 16:9 + 720p + 4s + 110 left +
+
+
+
+
+ +
+ + Production tables and review layouts use the same cards, badges, and + spacing rules so the system holds together even in denser workflows. + + +
+ +
+
+ Step + Status + Owner +
+ {[ + ["Mux ingest", "running", "Manager"], + ["Subtitle coverage", "completed", "Coverage"], + ["CMS sync", "pending", "Automation"], + ].map(([step, status, owner]) => ( +
+ + {step} + + + {status} + + {owner} +
+ ))} +
+
+ + +
+
+
+
+ +
+
+
+ + 0:19 + +
+
+
+ + + +
+
+

+ Enrichment queued +

+

+ 12 videos were handed off to jobs for subtitle generation and + language fill. +

+
+
+
+
+
+
+
+ +
+ + The design system points back to the real manager sources so we can + keep migrating by touching production components instead of + duplicating them in demo-only styling. + + +
+
+ ) +} diff --git a/apps/manager/src/app/dashboard/design-system/report-switcher.tsx b/apps/manager/src/app/dashboard/design-system/report-switcher.tsx new file mode 100644 index 000000000..860e95c32 --- /dev/null +++ b/apps/manager/src/app/dashboard/design-system/report-switcher.tsx @@ -0,0 +1,145 @@ +"use client" + +import type { LucideIcon } from "lucide-react" +import { + Captions, + ChevronDown, + FileAudio2, + FileJson2, + Sparkles, +} from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { cn } from "@/lib/utils" + +const reportOptions = [ + { + value: "subtitles", + label: "Subtitles", + subtitle: "Subtitle coverage for the selected language.", + icon: Captions, + }, + { + value: "audio", + label: "Audio", + subtitle: "Audio coverage for the selected language.", + icon: FileAudio2, + }, + { + value: "meta", + label: "Meta", + subtitle: "Metadata coverage for the selected language.", + icon: FileJson2, + }, + { + value: "experiences", + label: "Experiences", + subtitle: "Experience coverage for the selected language.", + icon: Sparkles, + }, +] as const + +type ReportOption = (typeof reportOptions)[number] + +function ReportIcon({ icon: Icon }: { icon: LucideIcon }) { + return