From b183c6a1543e4a9f0f11c8ee4b202d8030b84d31 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 07:37:00 +0200 Subject: [PATCH] feat(frontend): scaffold ChicoryTV SPA refs #78 --- .gitea/workflows/docker-build.yml | 27 + .gitignore | 3 + ErsatzTV.Tests/StartupSpaHostingTests.cs | 45 + ErsatzTV/Startup.cs | 47 +- design-system/Canvas.dc.html | 13 + design-system/SKILL.md | 17 + design-system/_adherence.oxlintrc.json | 470 ++ design-system/_ds_bundle.js | 3260 ++++++++++++++ design-system/_ds_manifest.json | 1 + design-system/assets/chicory-mark.svg | 8 + design-system/assets/chicorytv-icon.svg | 9 + design-system/assets/chicorytv-wordmark.svg | 9 + .../components/data-display/Badge.d.ts | 17 + .../components/data-display/Badge.jsx | 39 + .../components/data-display/Badge.prompt.md | 11 + .../components/data-display/Card.d.ts | 23 + .../components/data-display/Card.jsx | 38 + .../components/data-display/Card.prompt.md | 10 + .../components/data-display/ChannelLogo.d.ts | 17 + .../components/data-display/ChannelLogo.jsx | 51 + .../data-display/ChannelLogo.prompt.md | 8 + .../components/data-display/ProgressBar.d.ts | 16 + .../components/data-display/ProgressBar.jsx | 32 + .../data-display/ProgressBar.prompt.md | 8 + .../components/data-display/Stat.d.ts | 18 + .../components/data-display/Stat.jsx | 23 + .../components/data-display/Stat.prompt.md | 9 + .../components/data-display/StatusDot.d.ts | 16 + .../components/data-display/StatusDot.jsx | 37 + .../data-display/StatusDot.prompt.md | 9 + .../components/data-display/Tag.d.ts | 16 + design-system/components/data-display/Tag.jsx | 54 + .../components/data-display/Tag.prompt.md | 8 + .../data-display/data-display.card.html | 71 + .../components/feedback/Spinner.d.ts | 13 + design-system/components/feedback/Spinner.jsx | 17 + .../components/feedback/Spinner.prompt.md | 7 + design-system/components/feedback/Toast.d.ts | 16 + design-system/components/feedback/Toast.jsx | 46 + .../components/feedback/Toast.prompt.md | 8 + .../components/feedback/Tooltip.d.ts | 13 + design-system/components/feedback/Tooltip.jsx | 45 + .../components/feedback/Tooltip.prompt.md | 7 + .../components/feedback/feedback.card.html | 50 + design-system/components/forms/Button.d.ts | 27 + design-system/components/forms/Button.jsx | 107 + .../components/forms/Button.prompt.md | 11 + design-system/components/forms/Checkbox.d.ts | 16 + design-system/components/forms/Checkbox.jsx | 37 + .../components/forms/Checkbox.prompt.md | 8 + .../components/forms/IconButton.d.ts | 20 + design-system/components/forms/IconButton.jsx | 59 + .../components/forms/IconButton.prompt.md | 9 + design-system/components/forms/Input.d.ts | 24 + design-system/components/forms/Input.jsx | 90 + .../components/forms/Input.prompt.md | 9 + design-system/components/forms/Select.d.ts | 23 + design-system/components/forms/Select.jsx | 76 + .../components/forms/Select.prompt.md | 9 + design-system/components/forms/Switch.d.ts | 15 + design-system/components/forms/Switch.jsx | 48 + .../components/forms/Switch.prompt.md | 7 + .../components/forms/forms.card.html | 64 + .../components/navigation/NavItem.d.ts | 27 + .../components/navigation/NavItem.jsx | 55 + .../components/navigation/NavItem.prompt.md | 9 + design-system/components/navigation/Tabs.d.ts | 21 + design-system/components/navigation/Tabs.jsx | 50 + .../components/navigation/Tabs.prompt.md | 8 + .../navigation/navigation.card.html | 53 + .../ChannelBuilder.jsx | 619 +++ .../design_handoff_channel_builder/README.md | 163 + ...2026-07-01-chicorytv-ui-redesign-design.md | 229 + design-system/guidelines/brand-logo.card.html | 30 + .../guidelines/color-accent.card.html | 20 + .../guidelines/color-status.card.html | 21 + .../guidelines/color-surfaces.card.html | 20 + design-system/guidelines/color-text.card.html | 17 + .../guidelines/color-themes.card.html | 35 + .../guidelines/radius-elevation.card.html | 29 + .../guidelines/spacing-scale.card.html | 25 + design-system/guidelines/type-mono.card.html | 29 + design-system/guidelines/type-sans.card.html | 21 + design-system/readme.md | 190 + design-system/styles.css | 11 + design-system/support.js | 1658 +++++++ .../templates/chicorytv-admin/.thumbnail | Bin 0 -> 1042 bytes .../chicorytv-admin/ChannelBuilder.jsx | 619 +++ .../templates/chicorytv-admin/Channels.jsx | 188 + .../chicorytv-admin/ChicoryTvAdmin.dc.html | 19 + .../templates/chicorytv-admin/Dashboard.jsx | 145 + .../templates/chicorytv-admin/Epg.jsx | 108 + .../templates/chicorytv-admin/Playouts.jsx | 204 + .../chicorytv-admin/ScheduleEditor.jsx | 245 ++ .../chicorytv-admin/ScheduleLibrary.jsx | 117 + .../templates/chicorytv-admin/Shell.jsx | 144 + .../templates/chicorytv-admin/app.html | 142 + .../templates/chicorytv-admin/data.js | 130 + .../templates/chicorytv-admin/ds-base.js | 29 + .../templates/chicorytv-admin/icons.jsx | 18 + .../templates/chicorytv-admin/support.js | 1658 +++++++ design-system/tokens/colors.css | 65 + design-system/tokens/fonts.css | 5 + .../tokens/radius-elevation-motion.css | 27 + design-system/tokens/spacing.css | 35 + design-system/tokens/themes.css | 77 + design-system/tokens/typography.css | 45 + ...2026-07-01-chicorytv-ui-redesign-design.md | 134 + docker/Dockerfile | 12 + ...hicorytv-spa-scaffold-build-integration.md | 63 + web/eslint.config.js | 26 + web/index.html | 12 + web/package-lock.json | 3873 +++++++++++++++++ web/package.json | 36 + web/src/App.test.tsx | 15 + web/src/App.tsx | 86 + web/src/designSystem.ts | 3 + web/src/main.tsx | 11 + web/src/setupTests.ts | 1 + web/src/shell.css | 199 + web/src/vite-env.d.ts | 1 + web/tsconfig.app.json | 26 + web/tsconfig.json | 7 + web/tsconfig.node.json | 15 + web/vite.config.ts | 33 + 125 files changed, 17233 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Tests/StartupSpaHostingTests.cs create mode 100644 design-system/Canvas.dc.html create mode 100644 design-system/SKILL.md create mode 100644 design-system/_adherence.oxlintrc.json create mode 100644 design-system/_ds_bundle.js create mode 100644 design-system/_ds_manifest.json create mode 100644 design-system/assets/chicory-mark.svg create mode 100644 design-system/assets/chicorytv-icon.svg create mode 100644 design-system/assets/chicorytv-wordmark.svg create mode 100644 design-system/components/data-display/Badge.d.ts create mode 100644 design-system/components/data-display/Badge.jsx create mode 100644 design-system/components/data-display/Badge.prompt.md create mode 100644 design-system/components/data-display/Card.d.ts create mode 100644 design-system/components/data-display/Card.jsx create mode 100644 design-system/components/data-display/Card.prompt.md create mode 100644 design-system/components/data-display/ChannelLogo.d.ts create mode 100644 design-system/components/data-display/ChannelLogo.jsx create mode 100644 design-system/components/data-display/ChannelLogo.prompt.md create mode 100644 design-system/components/data-display/ProgressBar.d.ts create mode 100644 design-system/components/data-display/ProgressBar.jsx create mode 100644 design-system/components/data-display/ProgressBar.prompt.md create mode 100644 design-system/components/data-display/Stat.d.ts create mode 100644 design-system/components/data-display/Stat.jsx create mode 100644 design-system/components/data-display/Stat.prompt.md create mode 100644 design-system/components/data-display/StatusDot.d.ts create mode 100644 design-system/components/data-display/StatusDot.jsx create mode 100644 design-system/components/data-display/StatusDot.prompt.md create mode 100644 design-system/components/data-display/Tag.d.ts create mode 100644 design-system/components/data-display/Tag.jsx create mode 100644 design-system/components/data-display/Tag.prompt.md create mode 100644 design-system/components/data-display/data-display.card.html create mode 100644 design-system/components/feedback/Spinner.d.ts create mode 100644 design-system/components/feedback/Spinner.jsx create mode 100644 design-system/components/feedback/Spinner.prompt.md create mode 100644 design-system/components/feedback/Toast.d.ts create mode 100644 design-system/components/feedback/Toast.jsx create mode 100644 design-system/components/feedback/Toast.prompt.md create mode 100644 design-system/components/feedback/Tooltip.d.ts create mode 100644 design-system/components/feedback/Tooltip.jsx create mode 100644 design-system/components/feedback/Tooltip.prompt.md create mode 100644 design-system/components/feedback/feedback.card.html create mode 100644 design-system/components/forms/Button.d.ts create mode 100644 design-system/components/forms/Button.jsx create mode 100644 design-system/components/forms/Button.prompt.md create mode 100644 design-system/components/forms/Checkbox.d.ts create mode 100644 design-system/components/forms/Checkbox.jsx create mode 100644 design-system/components/forms/Checkbox.prompt.md create mode 100644 design-system/components/forms/IconButton.d.ts create mode 100644 design-system/components/forms/IconButton.jsx create mode 100644 design-system/components/forms/IconButton.prompt.md create mode 100644 design-system/components/forms/Input.d.ts create mode 100644 design-system/components/forms/Input.jsx create mode 100644 design-system/components/forms/Input.prompt.md create mode 100644 design-system/components/forms/Select.d.ts create mode 100644 design-system/components/forms/Select.jsx create mode 100644 design-system/components/forms/Select.prompt.md create mode 100644 design-system/components/forms/Switch.d.ts create mode 100644 design-system/components/forms/Switch.jsx create mode 100644 design-system/components/forms/Switch.prompt.md create mode 100644 design-system/components/forms/forms.card.html create mode 100644 design-system/components/navigation/NavItem.d.ts create mode 100644 design-system/components/navigation/NavItem.jsx create mode 100644 design-system/components/navigation/NavItem.prompt.md create mode 100644 design-system/components/navigation/Tabs.d.ts create mode 100644 design-system/components/navigation/Tabs.jsx create mode 100644 design-system/components/navigation/Tabs.prompt.md create mode 100644 design-system/components/navigation/navigation.card.html create mode 100644 design-system/design_handoff_channel_builder/ChannelBuilder.jsx create mode 100644 design-system/design_handoff_channel_builder/README.md create mode 100644 design-system/docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md create mode 100644 design-system/guidelines/brand-logo.card.html create mode 100644 design-system/guidelines/color-accent.card.html create mode 100644 design-system/guidelines/color-status.card.html create mode 100644 design-system/guidelines/color-surfaces.card.html create mode 100644 design-system/guidelines/color-text.card.html create mode 100644 design-system/guidelines/color-themes.card.html create mode 100644 design-system/guidelines/radius-elevation.card.html create mode 100644 design-system/guidelines/spacing-scale.card.html create mode 100644 design-system/guidelines/type-mono.card.html create mode 100644 design-system/guidelines/type-sans.card.html create mode 100644 design-system/readme.md create mode 100644 design-system/styles.css create mode 100644 design-system/support.js create mode 100644 design-system/templates/chicorytv-admin/.thumbnail create mode 100644 design-system/templates/chicorytv-admin/ChannelBuilder.jsx create mode 100644 design-system/templates/chicorytv-admin/Channels.jsx create mode 100644 design-system/templates/chicorytv-admin/ChicoryTvAdmin.dc.html create mode 100644 design-system/templates/chicorytv-admin/Dashboard.jsx create mode 100644 design-system/templates/chicorytv-admin/Epg.jsx create mode 100644 design-system/templates/chicorytv-admin/Playouts.jsx create mode 100644 design-system/templates/chicorytv-admin/ScheduleEditor.jsx create mode 100644 design-system/templates/chicorytv-admin/ScheduleLibrary.jsx create mode 100644 design-system/templates/chicorytv-admin/Shell.jsx create mode 100644 design-system/templates/chicorytv-admin/app.html create mode 100644 design-system/templates/chicorytv-admin/data.js create mode 100644 design-system/templates/chicorytv-admin/ds-base.js create mode 100644 design-system/templates/chicorytv-admin/icons.jsx create mode 100644 design-system/templates/chicorytv-admin/support.js create mode 100644 design-system/tokens/colors.css create mode 100644 design-system/tokens/fonts.css create mode 100644 design-system/tokens/radius-elevation-motion.css create mode 100644 design-system/tokens/spacing.css create mode 100644 design-system/tokens/themes.css create mode 100644 design-system/tokens/typography.css create mode 100644 design-system/uploads/2026-07-01-chicorytv-ui-redesign-design.md create mode 100644 docs/superpowers/plans/2026-07-02-chicorytv-spa-scaffold-build-integration.md create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/designSystem.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/setupTests.ts create mode 100644 web/src/shell.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 55157fe10..8ca3a240e 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -50,6 +50,33 @@ jobs: - name: Restore run: dotnet restore + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install SPA dependencies + working-directory: web + run: npm ci + + - name: Lint SPA + working-directory: web + run: npm run lint + + - name: Typecheck SPA + working-directory: web + run: npm run typecheck + + - name: Test SPA + working-directory: web + run: npm test -- --run + + - name: Build SPA + working-directory: web + run: npm run build + - name: Strip Scanner project ref (matches Docker build) run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj diff --git a/.gitignore b/.gitignore index f276affa0..2a77b0fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ scripts/download-test-content.sh docker-compose.override.yml ErsatzTV/wwwroot/v2/ +ErsatzTV/wwwroot/app/ +web/dist/ +web/node_modules/ diff --git a/ErsatzTV.Tests/StartupSpaHostingTests.cs b/ErsatzTV.Tests/StartupSpaHostingTests.cs new file mode 100644 index 000000000..715f861d1 --- /dev/null +++ b/ErsatzTV.Tests/StartupSpaHostingTests.cs @@ -0,0 +1,45 @@ +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests; + +[TestFixture] +public class StartupSpaHostingTests +{ + private static readonly string StartupSource = File.ReadAllText(FindStartupPath()); + + [Test] + public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path() + { + StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")"); + StartupSource.ShouldContain("MapWhen("); + StartupSource.ShouldContain("SpaStaticFileRoot"); + StartupSource.ShouldContain("RequestPath = \"/app\""); + StartupSource.ShouldContain("appIndexFile"); + } + + [Test] + public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback() + { + StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)"); + StartupSource.ShouldContain("bool IsSpaPath(PathString path)"); + } + + private static string FindStartupPath() + { + DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory); + + while (directory is not null) + { + string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs"); + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 0e333f292..71f265cca 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -681,7 +681,42 @@ public class Startup }); app.MapWhen( - ctx => !IsIptvPath(ctx.Request.Path), + ctx => ctx.Request.Path.StartsWithSegments("/app"), + spa => + { + string spaStaticFileRoot = SpaStaticFileRoot(); + IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot) + ? new PhysicalFileProvider(spaStaticFileRoot) + : new NullFileProvider(); + + spa.UseStaticFiles( + new StaticFileOptions + { + FileProvider = spaFileProvider, + RequestPath = "/app" + }); + + spa.Run( + async context => + { + Microsoft.Extensions.FileProviders.IFileInfo appIndexFile = + spaFileProvider.GetFileInfo("index.html"); + if (!appIndexFile.Exists) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength = appIndexFile.Length; + + await using Stream stream = appIndexFile.CreateReadStream(); + await stream.CopyToAsync(context.Response.Body); + }); + }); + + app.MapWhen( + ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path), blazor => { blazor.UseRouting(); @@ -736,6 +771,16 @@ public class Startup path.StartsWithSegments("/lineup.json") || path.StartsWithSegments("/lineup_status.json"); } + + bool IsSpaPath(PathString path) => path.StartsWithSegments("/app"); + + string SpaStaticFileRoot() + { + string webRootPath = CurrentEnvironment.WebRootPath ?? + Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot"); + + return Path.Combine(webRootPath, "app"); + } } private static void CustomServices(IServiceCollection services) diff --git a/design-system/Canvas.dc.html b/design-system/Canvas.dc.html new file mode 100644 index 000000000..c795a2fcd --- /dev/null +++ b/design-system/Canvas.dc.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/design-system/SKILL.md b/design-system/SKILL.md new file mode 100644 index 000000000..35b7a0d21 --- /dev/null +++ b/design-system/SKILL.md @@ -0,0 +1,17 @@ +--- +name: chicorytv-design +description: Use this skill to generate well-branded interfaces and assets for ChicoryTV, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping. +user-invocable: true +--- + +Read the README.md file within this skill, and explore the other available files. +If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand. +If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need. + +Quick map: +- `readme.md` — brand context, content & visual foundations, iconography, file index. +- `styles.css` — the single entry point; link it and use the CSS custom properties. +- `tokens/` — color, type, spacing, radius/elevation/motion tokens + font loading. +- `components/` — React primitives (forms, data-display, feedback, navigation). +- `templates/chicorytv-admin/` — full admin shell to start from / copy screens out of (incl. the library-to-lineup Channel Builder). +- `assets/` — chicory-flower mark, wordmark, app icon. diff --git a/design-system/_adherence.oxlintrc.json b/design-system/_adherence.oxlintrc.json new file mode 100644 index 000000000..9339acdb7 --- /dev/null +++ b/design-system/_adherence.oxlintrc.json @@ -0,0 +1,470 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/data-display/**", + "components/feedback/**", + "components/forms/**", + "components/navigation/**", + "design_handoff_channel_builder/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Geist|Geist Mono))/i]", + "message": "Font not provided by the design system. Available: Geist, Geist Mono." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|solid|dot|style|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: children, tone, solid, dot, style." + }, + { + "selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent|ok|warn|error)$/]", + "message": " tone must be one of 'neutral' | 'accent' | 'ok' | 'warn' | 'error'." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|startIcon|endIcon|disabled|loading|fullWidth|type|onClick|style|key|ref|className|style|children)$/]", + "message": " + ); +} diff --git a/design-system/components/navigation/Tabs.prompt.md b/design-system/components/navigation/Tabs.prompt.md new file mode 100644 index 000000000..f7298dbab --- /dev/null +++ b/design-system/components/navigation/Tabs.prompt.md @@ -0,0 +1,8 @@ +**Tabs** — underlined tab bar for editor sub-views and detail panes (Channel → General / Streaming / Watermark; Playout → Detail / Log). Active tab shows an accent underline. + +```jsx + +``` + +Props: `tabs` (string | {value,label,icon,count}), `value`, `onChange`. diff --git a/design-system/components/navigation/navigation.card.html b/design-system/components/navigation/navigation.card.html new file mode 100644 index 000000000..dbf54771c --- /dev/null +++ b/design-system/components/navigation/navigation.card.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + +
+ + + diff --git a/design-system/design_handoff_channel_builder/ChannelBuilder.jsx b/design-system/design_handoff_channel_builder/ChannelBuilder.jsx new file mode 100644 index 000000000..452c63399 --- /dev/null +++ b/design-system/design_handoff_channel_builder/ChannelBuilder.jsx @@ -0,0 +1,619 @@ +// Channel Builder — GROUND-UP replacement for the four-editor create chain +// (Channel -> Schedule -> Schedule Items -> Playout). One library-to-lineup screen. +// Left: searchable library browser. Center: the channel lineup (add by double-click +// or drag; drag to reorder). Right: friendly options + Channel Template + Advanced. +// Behind the scenes "Create" builds Channel + Collection + Schedule + Items + Playout. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Input, Select, Switch, Badge, ChannelLogo, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" }; + + // ---- Mock library (shows / movies / music / docs across libraries) -------- + const LIB = [ + { id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96 }, + { id: "s2", title: "Tom & Jerry", type: "Show", library: "Cartoons", sub: "164 shorts", min: 72 }, + { id: "s3", title: "Popeye", type: "Show", library: "Cartoons", sub: "108 shorts", min: 54 }, + { id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84 }, + { id: "s5", title: "Scooby-Doo", type: "Show", library: "Cartoons", sub: "97 episodes", min: 60 }, + { id: "s6", title: "Pink Panther", type: "Show", library: "Cartoons", sub: "124 shorts", min: 48 }, + { id: "m1", title: "Blade Runner", type: "Movie", library: "Movies", sub: "1982 · Sci-Fi", min: 117 }, + { id: "m2", title: "Alien", type: "Movie", library: "Movies", sub: "1979 · Horror", min: 117 }, + { id: "m3", title: "The Thing", type: "Movie", library: "Movies", sub: "1982 · Horror", min: 109 }, + { id: "m4", title: "Metropolis", type: "Movie", library: "Movies", sub: "1927 · Sci-Fi", min: 153 }, + { id: "m5", title: "Nosferatu", type: "Movie", library: "Movies", sub: "1922 · Horror", min: 94 }, + { id: "t1", title: "The Twilight Zone", type: "Show", library: "TV Shows", sub: "156 episodes", min: 51 }, + { id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50 }, + { id: "t3", title: "Columbo", type: "Show", library: "TV Shows", sub: "69 episodes", min: 94 }, + { id: "t4", title: "Cheers", type: "Show", library: "TV Shows", sub: "275 episodes", min: 24 }, + { id: "u1", title: "80s Synthpop", type: "Music", library: "Music Videos", sub: "212 videos", min: 45 }, + { id: "u2", title: "90s Alt Rock", type: "Music", library: "Music Videos", sub: "188 videos", min: 45 }, + { id: "u3", title: "Chart Countdown", type: "Music", library: "Music Videos", sub: "Top 40", min: 60 }, + { id: "d1", title: "Blue Planet", type: "Doc", library: "Documentaries", sub: "8 episodes", min: 50 }, + { id: "d2", title: "Planet Earth", type: "Doc", library: "Documentaries", sub: "11 episodes", min: 50 }, + { id: "d3", title: "Cosmos", type: "Doc", library: "Documentaries", sub: "13 episodes", min: 60 }, + ]; + const LIBRARIES = ["All", "Cartoons", "Movies", "TV Shows", "Music Videos", "Documentaries"]; + const TYPE_ICON = { Show: "Tv", Movie: "Film", Music: "Music", Doc: "Radio", Multi: "Layers", Smart: "Sparkles", Manual: "FolderTree", Rerun: "Repeat" }; + + // Collections — curated groups (Manual / Smart / Multi / Rerun) addable as a + // single lineup entry, so the builder isn't limited to raw library items. + const COLLECTIONS = [ + { id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true }, + { id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true }, + { id: "c3", title: "Retro Station IDs", type: "Manual", library: "Collection", sub: "Manual · 24 items", min: 12, coll: true }, + { id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly",min: 120, coll: true }, + { id: "c5", title: "Halloween Marathon", type: "Manual", library: "Collection", sub: "Manual · 9 films", min: 900, coll: true }, + ]; + + // Channel Templates — bundle the clunky technical config; one is pre-selected. + const TEMPLATES = [ + { id: "std", name: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, + sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] }, + { id: "music", name: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, + sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] }, + { id: "film", name: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, + sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] }, + { id: "retro", name: "Retro Saturday AM", builtin: false, desc: "Your saved cartoon-block template.", shuffle: true, always: true, + sets: ["1080p H.264", "HLS Segmenter", "Shuffle", "Always on", "Bumpers + ads"] }, + ]; + + // Advanced (~30-field) override set, grouped. Values shown as template-inherited. + const ADV = [ + { group: "Streaming", fields: [ + ["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920×1080"], + ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"], + ]}, + { group: "Filler", fields: [ + ["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], + ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"], + ]}, + { group: "Playback", fields: [ + ["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], + ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"], + ]}, + { group: "Behavior", fields: [ + ["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], + ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"], + ]}, + ]; + + // deterministic low-chroma poster hue from a title + function hueOf(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; } + + function fmtDur(totalMin) { + const h = Math.floor(totalMin / 60), m = totalMin % 60; + if (h && m) return h + "h " + String(m).padStart(2, "0") + "m"; + if (h) return h + "h"; + return m + "m"; + } + + // ---- Overlay scroll region — hides native bar (no reflow when it appears), + // shows a subtle thumb only while scrolling, fading out when idle. ------ + function ScrollArea({ children, innerStyle, scrollerRef }) { + const local = React.useRef(null); + const timer = React.useRef(null); + const [thumb, setThumb] = React.useState(null); + const setRef = (n) => { local.current = n; if (scrollerRef) scrollerRef.current = n; }; + const update = () => { + const el = local.current; if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight <= clientHeight + 2) { setThumb(null); return; } + const track = clientHeight - 8; + const h = Math.max(28, track * clientHeight / scrollHeight); + const top = 4 + (track - h) * (scrollTop / (scrollHeight - clientHeight)); + setThumb({ top, h }); + clearTimeout(timer.current); + timer.current = setTimeout(() => setThumb((t) => (t ? { ...t, hidden: true } : null)), 850); + }; + React.useEffect(() => { const id = setTimeout(update, 60); return () => { clearTimeout(id); clearTimeout(timer.current); }; }, []); + return ( +
+
+ {children} +
+ {thumb && ( +
+ )} +
+ ); + } + + // ---- Segmented control — iOS-style sliding active highlight --------------- + function Segmented({ value, options, onChange, style }) { + const idx = Math.max(0, options.findIndex((o) => o.value === value)); + return ( +
+
+ {options.map((o) => { + const on = o.value === value; + return ( + + ); + })} +
+ ); + } + + // ---- Poster placeholder (striped, tinted, mono meta) ---------------------- + function Poster({ item, w, h, mini }) { + const hue = hueOf(item.title); + return ( +
+
+
+ +
+ {!mini && ( +
+
{item.title}
+
{item.sub}
+
+ )} +
+ ); + } + + // ---- Library card ---------------------------------------------------------- + function LibCard({ item, added, compact, onAdd, onDragStart, onDragEnd }) { + if (compact) { + return ( +
+ +
+
{item.title}
+
{item.type} · {item.sub}
+
+ + + +
+ ); + } + return ( +
+
+ + {added && ( +
+ +
+ )} +
+
+ ); + } + + // ---- Lineup row ------------------------------------------------------------ + function LineupRow({ item, index, shuffle, dragging, isOver, onRemove, dragProps }) { + return ( +
+ + + {shuffle + ? + : {index + 1}} + + +
+
{item.title}
+
{item.type} · {item.library}
+
+ {fmtDur(item.min)} + +
+ ); + } + + // ---- Channel image dropzone + live logo/bug preview ----------------------- + function ImagePanel({ logoSrc, setLogoSrc, name }) { + const [sep, setSep] = React.useState(false); + const [over, setOver] = React.useState(false); + const fileRef = React.useRef(null); + const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => setLogoSrc(r.result); r.readAsDataURL(file); }; + return ( +
+
{ e.preventDefault(); setOver(true); }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }} + onClick={() => fileRef.current && fileRef.current.click()} + style={{ display: "flex", gap: 12, padding: 12, borderRadius: "var(--radius-sm)", cursor: "pointer", + background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}> + +
+
{logoSrc ? "Channel image set" : "Drop channel image"}
+
Used as both the guide logo and the on-screen bug.
+
+ read(e.target.files && e.target.files[0])} /> +
+ + {/* live preview: listing logo + on-screen bug */} +
+
+
Guide logo
+
+ +
+
{name || "New Channel"}
+
listing
+
+
+
+
+
On-screen bug
+
+
+ +
+
+
+
+ + + {sep && ( +
+ + +
+ )} +
+ ); + } + + // ---- Advanced disclosure — one-click Override (edit) or View defaults ------ + function Advanced({ templateName, scrollerRef }) { + const [mode, setMode] = React.useState("closed"); // closed | view | override + const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }); + const override = mode === "override"; + const count = ADV.reduce((n, g) => n + g.fields.length, 0); + const fieldsRef = React.useRef(null); + const rootRef = React.useRef(null); + const [maxH, setMaxH] = React.useState(0); + React.useLayoutEffect(() => { + if (mode === "closed") setMaxH(0); + else if (fieldsRef.current) setMaxH(fieldsRef.current.scrollHeight); + }, [mode]); + const toggle = (m) => { + const next = mode === m ? "closed" : m; + setMode(next); + if (next !== "closed" && scrollerRef && scrollerRef.current && rootRef.current) { + const sc = scrollerRef.current; + requestAnimationFrame(() => { + const target = sc.scrollTop + rootRef.current.getBoundingClientRect().top - sc.getBoundingClientRect().top - 20; + sc.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); + }); + } + }; + const tabBtn = (active) => ({ + display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", + borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", + background: active ? "color-mix(in srgb, var(--action-primary) 14%, transparent)" : "var(--ctv-surface-2)", + color: active ? "var(--action-primary)" : "var(--text-secondary)", + border: `1px solid ${active ? "color-mix(in srgb, var(--action-primary) 38%, transparent)" : "var(--border-hairline)"}`, + transition: "background 200ms cubic-bezier(0.16,1,0.3,1), color 200ms cubic-bezier(0.16,1,0.3,1), border-color 200ms cubic-bezier(0.16,1,0.3,1)", + }); + return ( +
+
+ + Advanced + + {count} fields · {templateName} +
+
+ + +
+
+
+
+ + {override ? "Editing these overrides the template for this channel only." : `Read-only — inherited from the ${templateName} template. Turn on Override to edit.`} +
+ {ADV.map((g) => ( +
+
{g.group}
+ {override ? ( +
+ {g.fields.map(([k]) => ( + setVals((s) => ({ ...s, [k]: e.target.value }))} /> + ))} +
+ ) : ( +
+ {g.fields.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ )} +
+ ))} +
+
+
+ ); + } + + // ---- Main ------------------------------------------------------------------ + function ChannelBuilder() { + const [q, setQ] = React.useState(""); + const [libFilter, setLibFilter] = React.useState("All"); + const [compact, setCompact] = React.useState(false); + const [lineup, setLineup] = React.useState(() => ["s1", "s2", "s3", "s4"].map((id) => LIB.find((x) => x.id === id))); + const [name, setName] = React.useState("Retro Cartoons"); + const [number, setNumber] = React.useState("13.1"); + const [shuffle, setShuffle] = React.useState(false); + const [always, setAlways] = React.useState(true); + const [logoSrc, setLogoSrc] = React.useState(null); + const [tpl, setTpl] = React.useState("std"); + const [tplOpen, setTplOpen] = React.useState(false); + const [source, setSource] = React.useState("library"); // library | collections + + // drag state + const [dragLib, setDragLib] = React.useState(null); // library item id being dragged + const [dragRow, setDragRow] = React.useState(null); // lineup index being dragged + const [overRow, setOverRow] = React.useState(null); + const railScrollerRef = React.useRef(null); + + const addedIds = new Set(lineup.map((i) => i.id)); + const pool = source === "collections" ? COLLECTIONS : LIB; + const filtered = pool.filter((i) => + (source === "collections" || libFilter === "All" || i.library === libFilter) && + (!q || i.title.toLowerCase().includes(q.toLowerCase()))); + + const add = (item) => setLineup((l) => (l.some((x) => x.id === item.id) ? l : [...l, item])); + const removeAt = (idx) => setLineup((l) => l.filter((_, i) => i !== idx)); + const totalMin = lineup.reduce((n, i) => n + i.min, 0); + const template = TEMPLATES.find((t) => t.id === tpl); + const canCreate = name.trim() && lineup.length > 0; + + // reorder / insert on drop at target index + const dropAt = (targetIdx) => { + if (dragRow != null) { + setLineup((l) => { const n = l.slice(); const [m] = n.splice(dragRow, 1); n.splice(targetIdx, 0, m); return n; }); + } else if (dragLib != null) { + const item = [...LIB, ...COLLECTIONS].find((x) => x.id === dragLib); + if (item && !addedIds.has(item.id)) setLineup((l) => { const n = l.slice(); n.splice(targetIdx, 0, item); return n; }); + } + setDragLib(null); setDragRow(null); setOverRow(null); + }; + const dropEnd = () => { + if (dragLib != null) { const item = [...LIB, ...COLLECTIONS].find((x) => x.id === dragLib); if (item) add(item); } + setDragLib(null); setDragRow(null); setOverRow(null); + }; + + return ( +
+ {/* builder toolbar */} +
+ +
+
New Channel
+
Build from your library — {lineup.length} in lineup · {fmtDur(totalMin)}
+
+ + + + +
+ + {/* body — library | lineup | settings */} +
+ {/* LEFT — library browser */} +
+
+
+ + + setCompact(v === "list")} options={[{ value: "grid", icon: "LayoutGrid", title: "Poster grid" }, { value: "list", icon: "Rows3", title: "Compact list" }]} /> +
+ setQ(e.target.value)} leadingIcon={} placeholder={source === "collections" ? "Search collections\u2026" : "Search shows & movies\u2026"} /> + {source === "library" && ( +
+ {LIBRARIES.map((l) => { + const on = libFilter === l; + return ( + + ); + })} +
+ )} +
+ + {filtered.length === 0 ? ( +
No titles match.
+ ) : compact ? ( +
+ {filtered.map((item) => ( + add(item)} + onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} /> + ))} +
+ ) : ( +
+ {filtered.map((item) => ( + add(item)} + onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} /> + ))} +
+ )} +
+
+ + {/* CENTER — lineup */} +
{ if (dragLib != null) e.preventDefault(); }} + onDrop={() => { if (dragLib != null) dropEnd(); }}> +
+ Lineup + {shuffle ? "Shuffled" : "Sequential"} + + {lineup.length > 0 && } +
+ + {lineup.length === 0 ? ( +
+ +
This channel has no content yet. Double-click or drag titles from the library to build the lineup.
+
+ ) : ( +
+ {lineup.map((item, idx) => ( + removeAt(idx)} + dragProps={{ + draggable: true, + onDragStart: () => setDragRow(idx), + onDragEnd: () => { setDragRow(null); setOverRow(null); }, + onDragOver: (e) => { e.preventDefault(); setOverRow(idx); }, + onDrop: (e) => { e.stopPropagation(); dropAt(idx); }, + }} /> + ))} +
+ Double-click or drag more titles to add +
+
+ )} +
+
+ + {/* RIGHT — friendly options + template + advanced */} + +
+
+ ); + } + + window.CTVChannelBuilder = ChannelBuilder; +})(); diff --git a/design-system/design_handoff_channel_builder/README.md b/design-system/design_handoff_channel_builder/README.md new file mode 100644 index 000000000..bf3988888 --- /dev/null +++ b/design-system/design_handoff_channel_builder/README.md @@ -0,0 +1,163 @@ +# Handoff: ChicoryTV — Channel Builder (+ admin design system) + +## Overview +The **Channel Builder** ("New Channel") is a ground-up replacement for ErsatzTV's four-editor +channel-creation chain (Channel → Schedule → Schedule Items → Playout). It is **one screen**: browse +your media library on the left, build the channel's lineup in the center, set friendly options + +a Channel Template on the right, and hit **Create Channel** — which (in the real app) builds all the +underlying entities atomically. It ships inside the **ChicoryTV admin**, a dark, dense, app-like +console and its React component library. + +## About the design files +The files in this bundle are **design references created in HTML/React** — prototypes showing the +intended look and behavior, **not production code to ship directly**. The task is to **recreate these +designs in the target codebase's environment** using its established patterns. Per the redesign brief, +the intended target is a **React SPA over the ErsatzTV REST API** (replacing Blazor/MudBlazor); if that +app doesn't exist yet, scaffold it (Vite + React + TypeScript is a reasonable default) and implement +there. Nothing here depends on Blazor. + +The prototypes are built on a real design system (React primitives + CSS-custom-property tokens + +three themes) that lives in the parent project — treat that as the component/token source of truth. + +## Fidelity +**High-fidelity.** Final colors, typography, spacing, radii, motion, and interactions are all resolved. +Recreate pixel-for-pixel using the codebase's real component library (promoting these primitives into +it, or mapping to existing equivalents). All values below are exact. + +--- + +## Screens / Views + +### New Channel — Channel Builder (primary deliverable) +**Purpose:** create a working channel by adding library items to a lineup and setting a few friendly +options; technical config comes from a selectable Channel Template. + +**Layout:** full-height column. A **builder toolbar** (fixed, 12px×20px padding, hairline bottom +border): accent icon tile + "New Channel" title + live summary (`N in lineup · total duration`) on the +left; **Cancel** (ghost) and **Create Channel** (primary, right) — Create is disabled until there's a +name + ≥1 lineup item, tooltip below. Below the toolbar, a 3-column grid, full height: +`grid-template-columns: minmax(0,1.2fr) minmax(0,1fr) 304px` (columns shrink, never overflow; the +304px rail is fixed). Each column scrolls independently. + +**Column 1 — Library browser** (`overflow:hidden` section): +- Header: a **Segmented** source toggle **Library / Collections** (sliding highlight), a spacer, and a + grid/compact **density** Segmented. Then a search input (`Search shows & movies…` / + `Search collections…`). Then, in Library mode only, pill **filter chips** (All, Cartoons, Movies, + TV Shows, Music Videos, Documentaries) — active chip uses accent-soft bg + accent text. +- Body (scrolls): **poster grid** `repeat(auto-fill, minmax(104px,1fr))`, gap 10 — each poster is a + 2:3 tinted, diagonally-striped placeholder (deterministic hue from title) with a bottom gradient + scrim, title (12px semibold) + mono meta, and a type icon top-left (Tv/Film/Music/Radio, or + Layers/Sparkles/FolderTree/Repeat for collection kinds). Compact mode is a 30×40 thumb + title + + `type · meta` row. **Double-click or drag** to add; added cards show an accent ring + a **pop-in** + check badge (top-right). + +**Column 2 — Lineup** (the channel's content, `overflow:hidden` section, accepts drops): +- Header: "LINEUP" eyebrow, a **Sequential / Shuffled** status badge (icon, informational — the real + control is the Shuffle toggle in the rail), a spacer, a **Clear** ghost button. +- Body (scrolls): ordered rows, gap 7. Each row: grip handle, order number (mono) when Sequential or a + shuffle glyph when Shuffled, a 34×46 poster thumb, title (14px semibold) + `type · library`, mono + duration, and a remove (×) button. **Drag-to-reorder** (HTML5 DnD; a `0 -2px 0 accent` top-border + marks the drop target; dragged row drops to 0.35 opacity). Empty state = dashed dropzone. A trailing + dashed "double-click or drag more titles to add" hint. + +**Column 3 — Settings rail** (fixed 304px, `background: surface-card`, scrolls via an overlay +scrollbar — hidden native bar so no reflow, subtle fading thumb): +- **Channel name** input; **Number** input with an `AUTO` mono chip trailing. +- **Channel image**: a dashed dropzone (drop or click → file input; live `FileReader` preview). Below, + two preview tiles — **Guide logo** (logo + name + "listing") and **On-screen bug** (logo bottom-right + over a striped "video frame"). A "Set logo & bug separately" disclosure reveals two image buttons. + Default: the one image drives both. +- **Shuffle** toggle (label + "Plays in a random order." / "Plays in sequence (chronological)."). Shows + an "overrides template" tag when it differs from the selected template's default. +- **Always playing** toggle — the live-TV concept. Rendered in the **live color** (`--ctv-live`) with a + tinted row + a "● LIVE" pill when on. Helper: "Like live TV — advances on schedule even when nobody is + watching." Also shows the "overrides template" tag when diverging. *(Backend gap #68 — still needs + confirmation.)* +- **Channel Template** picker: a button showing the current template (name + one-line desc + Custom + badge). Opens a list of built-ins + custom, each with a radio, name, desc; a "Save current settings as + template…" action. Selecting a template **applies its Shuffle + Always-playing defaults** and shows + its config as chips (e.g. `1080p H.264`, `HLS Segmenter`, `Sequential`, `Always on`, `Pre + post filler`). +- **Advanced** disclosure: a **SlidersHorizontal** header + `N fields ·