From f76f8a939cc61710a5f11de578f6ae2d5971dc90 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 01:14:11 +0200 Subject: [PATCH] design(388): mirror full ChicoryTV design system to prod + design-sync reminder hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk mirror sweep (#388): author/rebuild a prototype mockup for every shipped SPA screen so the Claude Design project (eb3b6122 / local design-system/) faithfully reflects prod. - 40 screens authored (mockups: visual/layout parity, no logic) via a fan-out workflow; 8 stale prototypes rebuilt (Dashboard, ChannelBuilder, Guide, Schedules, Playouts, Settings, …), 32 net-new (media/sources/system/auth). Retired Epg/ScheduleEditor/ScheduleLibrary. - New screens.js = single source of truth for the inventory; Shell.jsx nav + app.html screen map both build from it (nav groups mirror web/src/app/routes.tsx). app.html is now hash-routed. - All 42 views verified rendering headlessly across the 3 themes (126/126, 0 real errors). - design-sync-reminder.sh hook (+ settings.json): mechanical, fail-open, once/session nudge to pull-first (PreToolUse Write|Edit on web/src/**.{tsx,css}) and mirror/push-back (Stop) — keeps the design system from drifting from prod going forward. - docs/design-sync.md: document the bulk-sweep structure + the reminder hook. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/design-sync-reminder.sh | 58 +++ .claude/settings.json | 21 + .../templates/chicorytv-admin/ApiKey.jsx | 241 +++++++++++ .../BlockPlayoutTroubleshooting.jsx | 228 ++++++++++ .../templates/chicorytv-admin/Blocks.jsx | 281 +++++++++++++ .../chicorytv-admin/ChannelBuilder.jsx | 170 ++++++-- .../templates/chicorytv-admin/ChannelEdit.jsx | 335 +++++++++++++++ .../templates/chicorytv-admin/Collections.jsx | 183 ++++++++ .../templates/chicorytv-admin/Dashboard.jsx | 271 +++++++----- .../chicorytv-admin/DecoTemplates.jsx | 204 +++++++++ .../templates/chicorytv-admin/Decos.jsx | 341 +++++++++++++++ .../templates/chicorytv-admin/Epg.jsx | 108 ----- .../chicorytv-admin/FFmpegProfiles.jsx | 335 +++++++++++++++ .../chicorytv-admin/FillerPresets.jsx | 146 +++++++ .../templates/chicorytv-admin/Guide.jsx | 181 ++++++++ .../chicorytv-admin/ImageBrowser.jsx | 195 +++++++++ .../templates/chicorytv-admin/Libraries.jsx | 265 ++++++++++++ .../chicorytv-admin/LocalLibraryEdit.jsx | 284 +++++++++++++ .../templates/chicorytv-admin/Login.jsx | 115 +++++ .../templates/chicorytv-admin/Logs.jsx | 319 ++++++++++++++ .../templates/chicorytv-admin/Media.jsx | 250 +++++++++++ .../templates/chicorytv-admin/MediaDetail.jsx | 328 +++++++++++++++ .../chicorytv-admin/MultiCollections.jsx | 243 +++++++++++ .../chicorytv-admin/PathReplacementsEdit.jsx | 146 +++++++ .../PlaybackTroubleshooting.jsx | 191 +++++++++ .../templates/chicorytv-admin/Playlists.jsx | 206 +++++++++ .../PlayoutScheduleEditors.jsx | 340 +++++++++++++++ .../templates/chicorytv-admin/Playouts.jsx | 396 ++++++++++-------- .../templates/chicorytv-admin/PlexSource.jsx | 147 +++++++ .../chicorytv-admin/RemoteConnectionEdit.jsx | 110 +++++ .../chicorytv-admin/RemoteLibrariesEdit.jsx | 144 +++++++ .../chicorytv-admin/RemoteSource.jsx | 127 ++++++ .../chicorytv-admin/RerunCollections.jsx | 200 +++++++++ .../chicorytv-admin/ScheduleEditor.jsx | 245 ----------- .../chicorytv-admin/ScheduleLibrary.jsx | 117 ------ .../templates/chicorytv-admin/Schedules.jsx | 356 ++++++++++++++++ .../templates/chicorytv-admin/Search.jsx | 203 +++++++++ .../templates/chicorytv-admin/Settings.jsx | 166 +++++--- .../templates/chicorytv-admin/Setup.jsx | 147 +++++++ .../templates/chicorytv-admin/Shell.jsx | 48 +-- .../templates/chicorytv-admin/Templates.jsx | 298 +++++++++++++ .../templates/chicorytv-admin/TraktLists.jsx | 161 +++++++ .../templates/chicorytv-admin/Trash.jsx | 372 ++++++++++++++++ .../chicorytv-admin/Troubleshooting.jsx | 219 ++++++++++ .../templates/chicorytv-admin/Watermarks.jsx | 156 +++++++ .../chicorytv-admin/YamlValidator.jsx | 174 ++++++++ .../templates/chicorytv-admin/app.html | 88 ++-- .../templates/chicorytv-admin/screens.js | 63 +++ docs/design-sync.md | 28 ++ 49 files changed, 9031 insertions(+), 919 deletions(-) create mode 100755 .claude/hooks/design-sync-reminder.sh create mode 100644 design-system/templates/chicorytv-admin/ApiKey.jsx create mode 100644 design-system/templates/chicorytv-admin/BlockPlayoutTroubleshooting.jsx create mode 100644 design-system/templates/chicorytv-admin/Blocks.jsx create mode 100644 design-system/templates/chicorytv-admin/ChannelEdit.jsx create mode 100644 design-system/templates/chicorytv-admin/Collections.jsx create mode 100644 design-system/templates/chicorytv-admin/DecoTemplates.jsx create mode 100644 design-system/templates/chicorytv-admin/Decos.jsx delete mode 100644 design-system/templates/chicorytv-admin/Epg.jsx create mode 100644 design-system/templates/chicorytv-admin/FFmpegProfiles.jsx create mode 100644 design-system/templates/chicorytv-admin/FillerPresets.jsx create mode 100644 design-system/templates/chicorytv-admin/Guide.jsx create mode 100644 design-system/templates/chicorytv-admin/ImageBrowser.jsx create mode 100644 design-system/templates/chicorytv-admin/Libraries.jsx create mode 100644 design-system/templates/chicorytv-admin/LocalLibraryEdit.jsx create mode 100644 design-system/templates/chicorytv-admin/Login.jsx create mode 100644 design-system/templates/chicorytv-admin/Logs.jsx create mode 100644 design-system/templates/chicorytv-admin/Media.jsx create mode 100644 design-system/templates/chicorytv-admin/MediaDetail.jsx create mode 100644 design-system/templates/chicorytv-admin/MultiCollections.jsx create mode 100644 design-system/templates/chicorytv-admin/PathReplacementsEdit.jsx create mode 100644 design-system/templates/chicorytv-admin/PlaybackTroubleshooting.jsx create mode 100644 design-system/templates/chicorytv-admin/Playlists.jsx create mode 100644 design-system/templates/chicorytv-admin/PlayoutScheduleEditors.jsx create mode 100644 design-system/templates/chicorytv-admin/PlexSource.jsx create mode 100644 design-system/templates/chicorytv-admin/RemoteConnectionEdit.jsx create mode 100644 design-system/templates/chicorytv-admin/RemoteLibrariesEdit.jsx create mode 100644 design-system/templates/chicorytv-admin/RemoteSource.jsx create mode 100644 design-system/templates/chicorytv-admin/RerunCollections.jsx delete mode 100644 design-system/templates/chicorytv-admin/ScheduleEditor.jsx delete mode 100644 design-system/templates/chicorytv-admin/ScheduleLibrary.jsx create mode 100644 design-system/templates/chicorytv-admin/Schedules.jsx create mode 100644 design-system/templates/chicorytv-admin/Search.jsx create mode 100644 design-system/templates/chicorytv-admin/Setup.jsx create mode 100644 design-system/templates/chicorytv-admin/Templates.jsx create mode 100644 design-system/templates/chicorytv-admin/TraktLists.jsx create mode 100644 design-system/templates/chicorytv-admin/Trash.jsx create mode 100644 design-system/templates/chicorytv-admin/Troubleshooting.jsx create mode 100644 design-system/templates/chicorytv-admin/Watermarks.jsx create mode 100644 design-system/templates/chicorytv-admin/YamlValidator.jsx create mode 100644 design-system/templates/chicorytv-admin/screens.js diff --git a/.claude/hooks/design-sync-reminder.sh b/.claude/hooks/design-sync-reminder.sh new file mode 100755 index 000000000..43615897f --- /dev/null +++ b/.claude/hooks/design-sync-reminder.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# design-sync-reminder — single hook, both directions (#388). Keeps the Claude Design project +# (`ChicoryTV Design System`, eb3b6122 / local mirror `design-system/`) in step with the shipped +# SPA. Trigger is PURELY MECHANICAL: "touching the UI" == a file matching UI_RE below. No prompt +# keyword guessing. Wired to two boundaries: +# +# start (PreToolUse / Write|Edit) — the FIRST time this session edits a UI file, remind to PULL +# the current design from Claude Design first. +# finish (Stop) — if the working tree actually changed a UI file, remind to +# MIRROR/PUSH the change back before wrapping up. +# +# UI_RE is the one place the "what counts as UI" fileset is defined: SPA .tsx/.css under web/src +# (test files excluded). Widen it here if the design surface grows. +# +# Fail-open: any parse trouble / non-match → emit nothing, exit 0. Throttled once per session per +# phase so it informs without nagging. DesignSync runs only from the main session (docs/design-sync.md). +# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge. +set -euo pipefail + +UI_RE='(^|/)web/src/.*\.(tsx|css)$' +TEST_RE='\.test\.(tsx|ts)$' + +phase="${1:-}" +input=$(cat) +me=$(printf '%s' "$input" | jq -r '.session_id // "nosess"' 2>/dev/null || true) +cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true) +[ -z "$cwd" ] && cwd="$PWD" +marker="${TMPDIR:-/tmp}/ctv-designsync-${phase}-${me}" + +case "$phase" in + start) + fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true) + [ -z "$fp" ] && exit 0 + printf '%s' "$fp" | grep -qE "$TEST_RE" && exit 0 # skip test files + printf '%s' "$fp" | grep -qE "$UI_RE" || exit 0 # not a UI file → nothing + [ -f "$marker" ] && exit 0 + : > "$marker" 2>/dev/null || true + read -r -d '' MSG <<'EOF' || true +[design-sync #388] About to edit a ChicoryTV SPA UI file. The `design-system/` prototypes mirror the Claude Design project (eb3b6122). If you're changing how a screen LOOKS, first PULL its current prototype from Claude Design so you start from the live design (docs/design-sync.md, pull = DesignSync list_files/get_file → design-system/, incremental). You'll be reminded to MIRROR the change back when the task finishes. DesignSync runs only from the main session. +EOF + jq -n --arg m "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}' + exit 0 + ;; + finish) + # Did this turn actually change a UI file? (tracked diff vs HEAD + untracked, minus tests) + changed=$( { git -C "$cwd" diff --name-only HEAD 2>/dev/null; git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null; } | grep -vE "$TEST_RE" | grep -E "$UI_RE" || true ) + [ -z "$changed" ] && exit 0 + [ -f "$marker" ] && exit 0 + : > "$marker" 2>/dev/null || true + n=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ') + reason="[design-sync #388] This task changed ${n} SPA UI file(s) under web/src. Before wrapping up, MIRROR the visual change into the matching design-system/templates/chicorytv-admin/*.jsx prototype and push it to Claude Design (eb3b6122) in this same session, per docs/design-sync.md — so the design system does not drift from prod. If you already synced, or are deliberately deferring the mirror (say why), just note it and stop. DesignSync runs only from the main session. This one-shot reminder won't fire again this session." + jq -n --arg r "$reason" '{decision:"block",reason:$r}' + exit 0 + ;; + *) + exit 0 + ;; +esac diff --git a/.claude/settings.json b/.claude/settings.json index da1c83a68..ac251eb55 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -46,6 +46,16 @@ "timeout": 15 } ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" start", + "timeout": 10 + } + ] } ], "PostToolUse": [ @@ -59,6 +69,17 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish", + "timeout": 10 + } + ] + } ] } } diff --git a/design-system/templates/chicorytv-admin/ApiKey.jsx b/design-system/templates/chicorytv-admin/ApiKey.jsx new file mode 100644 index 000000000..4527da0ab --- /dev/null +++ b/design-system/templates/chicorytv-admin/ApiKey.jsx @@ -0,0 +1,241 @@ +// API Key screen — machine key management and local admin password change. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Card, Input, Spinner } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + // Mock data + const MACHINE_KEY = "etv_0b4f8c2a1e9d5f3b7a6c4e2d0f1a8b9c5d3e2f1a0b4c6d8e9f0a1b2c3d4e5f6g"; + const mockAuthMethod = "local"; // 'local' or 'oidc' — determines if password card shows + + function CardFrame({ title, subtitle, children }) { + return ( +
+ {title && ( +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ )} + {children} +
+ ); + } + + function MachineKeyCard() { + const [revealed, setRevealed] = React.useState(false); + const [copied, setCopied] = React.useState(false); + + const copy = () => { + navigator.clipboard?.writeText(MACHINE_KEY).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + + {/* Success callout */} +
+ + + Give this key to an MCP server or external REST client to let it call this server's API. + +
+ + {/* Machine key field */} +
+ +
+ + + {revealed ? MACHINE_KEY : "••••••••••••••••••••••••"} + +
+
+ + {/* Action buttons */} +
+ + +
+
+ ); + } + + function LocalPasswordCard() { + const [currentPassword, setCurrentPassword] = React.useState(""); + const [newPassword, setNewPassword] = React.useState(""); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); + const [justSaved, setJustSaved] = React.useState(false); + + const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && !saving; + + const submit = () => { + if (!canSubmit) return; + setSaving(true); + setError(null); + setJustSaved(false); + // Simulate API call + setTimeout(() => { + setCurrentPassword(""); + setNewPassword(""); + setJustSaved(true); + setSaving(false); + }, 800); + }; + + return ( + + {error && ( +
+ + + {error} + +
+ )} + + {/* Form fields */} +
+ { + setCurrentPassword(e.target.value); + setJustSaved(false); + }} + leadingIcon={} + placeholder="Enter your current password" + /> + { + setNewPassword(e.target.value); + setJustSaved(false); + }} + leadingIcon={} + placeholder="Enter your new password" + /> +
+ + {/* Action bar */} +
+ {justSaved && ( + + + Password changed. + + )} + {!justSaved && } + +
+
+ ); + } + + function ApiKey() { + return ( +
+ + {mockAuthMethod === "local" && } +
+ ); + } + + window.CTVApiKey = ApiKey; +})(); diff --git a/design-system/templates/chicorytv-admin/BlockPlayoutTroubleshooting.jsx b/design-system/templates/chicorytv-admin/BlockPlayoutTroubleshooting.jsx new file mode 100644 index 000000000..f618ec561 --- /dev/null +++ b/design-system/templates/chicorytv-admin/BlockPlayoutTroubleshooting.jsx @@ -0,0 +1,228 @@ +// Block Playout Troubleshooting screen — playout picker → grouped block table → +// per-block history log → decoded history-row detail panel. A drill-down diagnostic +// tool, not a management surface: dense tables, monospace keys, quiet chrome. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Card, Input, Select } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const PLAYOUTS = [ + { value: "", label: "Select a block playout…" }, + { value: "12", label: "1.1 - WeekdayMornings" }, + { value: "18", label: "4.2 - Retro80s" }, + { value: "23", label: "9.1 - KidsAllDay" }, + ]; + + const BLOCKS = [ + { id: 101, group: "Morning", name: "Wake Up Block", minutes: 60 }, + { id: 102, group: "Morning", name: "Cartoon Hour", minutes: 60 }, + { id: 103, group: "Morning", name: "News Bridge", minutes: 30 }, + { id: 201, group: "Afternoon", name: "Sitcom Rerun Block", minutes: 90 }, + { id: 202, group: "Afternoon", name: "Movie Matinee", minutes: 120 }, + { id: -1, group: "Afternoon", name: "Filler (synthesized)", minutes: 15 }, + { id: 301, group: "Prime", name: "Drama Block", minutes: 60 }, + { id: 302, group: "Prime", name: "Late Night Block", minutes: 45 }, + ]; + + const HISTORY = [ + { id: 9001, when: "2026-07-17T06:00:00", finish: "2026-07-17T07:00:00", key: "2026-07-17|06:00|101", details: "Collection:GlobalWatch(shuffle)" }, + { id: 9002, when: "2026-07-16T06:00:00", finish: "2026-07-16T07:00:00", key: "2026-07-16|06:00|101", details: "Collection:GlobalWatch(shuffle)" }, + { id: 9003, when: "2026-07-15T06:00:00", finish: "2026-07-15T07:00:00", key: "2026-07-15|06:00|101", details: "Collection:GlobalWatch(shuffle)" }, + { id: 9004, when: "2026-07-14T06:00:00", finish: "2026-07-14T07:00:00", key: "2026-07-14|06:00|101", details: "Collection:GlobalWatch(chrono)" }, + { id: 9005, when: "2026-07-13T06:00:00", finish: "2026-07-13T07:00:00", key: "2026-07-13|06:00|101", details: "MultiCollection:MorningMix(shuffle)" }, + { id: 9006, when: "2026-07-12T06:00:00", finish: "2026-07-12T07:00:00", key: "2026-07-12|06:00|101", details: "Collection:GlobalWatch(shuffle)" }, + ]; + + const DETAILS = { + 9001: { playbackOrder: "Shuffle", collectionType: "Collection", name: "GlobalWatch", mediaItemType: "Episode", mediaItemTitle: "Tom & Jerry — The Cat Concerto" }, + }; + + function formatDateTime(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + } + + function DetailRow({ label, value }) { + return ( +
+
{label}
+
{value === "" ? "—" : value}
+
+ ); + } + + const actionbar = { display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }; + const section = { padding: "16px 20px 0" }; + const tableWrap = { border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }; + const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", position: "sticky", top: 0, background: "var(--surface-card)", borderBottom: "1px solid var(--border-hairline)", zIndex: 2 }; + const td = { padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" }; + + function BlockPlayoutTroubleshooting() { + const [playoutId, setPlayoutId] = React.useState("18"); + const [blockFilter, setBlockFilter] = React.useState(""); + const [selectedBlockId, setSelectedBlockId] = React.useState(102); + const [selectedHistoryId, setSelectedHistoryId] = React.useState(9001); + const [pageSize, setPageSize] = React.useState("10"); + const [hoverBlock, setHoverBlock] = React.useState(null); + const [hoverRow, setHoverRow] = React.useState(null); + + const needle = blockFilter.trim().toLowerCase(); + const filtered = needle === "" ? BLOCKS : BLOCKS.filter((b) => b.name.toLowerCase().includes(needle)); + const groups = []; + const gmap = {}; + filtered.forEach((b) => { if (!gmap[b.group]) { gmap[b.group] = []; groups.push(b.group); } gmap[b.group].push(b); }); + + const selectedBlock = BLOCKS.find((b) => b.id === selectedBlockId) || null; + const details = DETAILS[selectedHistoryId] || null; + + return ( +
+ {/* playout picker */} +
+ setBlockFilter(e.target.value)} + placeholder="Search for blocks…" + leadingIcon={} + style={{ minWidth: 280 }} + /> +
+ + {/* blocks table */} +
+
+ + + + + + + + + + {groups.map((g) => ( + + + + + {gmap[g].map((b) => { + const isSel = selectedBlockId === b.id; + const isHover = hoverBlock === b.id; + return ( + setHoverBlock(b.id)} + onMouseLeave={() => setHoverBlock(null)} + style={{ borderTop: "1px solid var(--border-hairline)", background: isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }} + > + + + + + ); + })} + + ))} + +
BlockMinutesHistory
+ {g || "Ungrouped"} +
{b.name}{b.minutes} + {b.id >= 0 && ( + + )} +
+
+
+ + {/* history table */} + {selectedBlock && ( +
+
+ + History — {selectedBlock.name} + + + } placeholder="Search for blocks…" value="" onChange={() => {}} /> +
+
+ +
+ {GROUPS.map((g) => ( + + + +
+ } + > + {g.blocks.length === 0 ? ( +
No blocks in this group.
+ ) : ( + g.blocks.map((b, i) => ( + + + + {b.minutes} min + + + + )) + )} + + ))} +
+ + ); + } + + function BlockEditor({ onBack }) { + const [selectedKey, setSelectedKey] = React.useState("i2"); + const selected = ITEMS.find((it) => it.key === selectedKey) || null; + + return ( +
+
+ + Anime Blocks / Edit block + + + +
+ +
+ + + {}} /> + + + {}} /> + + + {}} /> + + + {}} options={["Before Duration End", "After Duration End"]} /> + + + + }>Add item}> +
+ + + + + + + + + + + + {ITEMS.map((it, index) => { + const isSel = it.key === selectedKey; + return ( + setSelectedKey(it.key)} + style={{ + cursor: "pointer", + background: isSel ? "var(--ctv-accent-soft)" : "transparent", + borderTop: index === 0 ? "none" : "1px solid var(--border-hairline)", + }} + > + + + + + + + ); + })} + +
CollectionPlayback orderShow in EPGDisable watermarks
+
+ {it.collection} + {it.type} +
+
{it.order} e.stopPropagation()}> + {}} /> + e.stopPropagation()}> + {}} /> + e.stopPropagation()}> +
+ + + + +
+
+
+
+ + {selected && ( + + + {}} /> +
+ + + setVals((s) => ({ ...s, [k]: e.target.value }))} /> - ))} + {g.fields.map((f) => { + if (f.kind === "switch") { + const on = vals[f.k] === "On"; + return ( +
+ {f.k} + setVals((s) => ({ ...s, [f.k]: next ? "On" : "Off" }))} /> +
+ ); + } + if (f.kind === "select") { + return ( + setVals((s) => ({ ...s, [f.k]: e.target.value }))} /> + ); + })}
) : (
- {g.fields.map(([k, v]) => ( -
-
{k}
-
{v}
+ {g.fields.filter((f) => f.kind !== "switch" && f.viewOk !== false).map((f) => ( +
+
{f.k}
+
{vals[f.k]}
))}
@@ -378,6 +437,7 @@ const [tpl, setTpl] = React.useState("std"); const [tplOpen, setTplOpen] = React.useState(false); const [source, setSource] = React.useState("library"); // library | collections + const [seasonsFor, setSeasonsFor] = React.useState(null); // show whose seasons drill-in is open // drag state const [dragLib, setDragLib] = React.useState(null); // library item id being dragged @@ -414,13 +474,11 @@ return (
- {/* builder toolbar */} + {/* builder toolbar — the shell's TopBar already renders the "New Channel" + page title, so this row is just the live summary + the two actions. */}
- -
-
New Channel
-
Build from your library — {lineup.length} in lineup · {fmtDur(totalMin)}
-
+ Build from your library — {lineup.length} in lineup · {fmtDur(totalMin)} + @@ -461,6 +519,7 @@
{filtered.map((item) => ( add(item)} + onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined} onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} /> ))}
@@ -468,6 +527,7 @@
{filtered.map((item) => ( add(item)} + onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined} onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} /> ))}
@@ -611,6 +671,34 @@
+ + {/* Seasons drill-in — expands a TelevisionShow into its seasons so one + season can be added instead of the whole show (issue #180). */} + {seasonsFor && ( +
setSeasonsFor(null)}> +
e.stopPropagation()} + style={{ width: 420, maxHeight: "70vh", display: "flex", flexDirection: "column", borderRadius: "var(--radius-lg)", background: "var(--surface-card)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-lg)" }}> +
+ Seasons — {seasonsFor.title} + + setSeasonsFor(null)}> +
+
+ {["Season 1", "Season 2", "Season 3", "Specials"].map((s) => ( +
+ +
{s}
+ +
+ ))} +
+
+ +
+
+
+ )}
); } diff --git a/design-system/templates/chicorytv-admin/ChannelEdit.jsx b/design-system/templates/chicorytv-admin/ChannelEdit.jsx new file mode 100644 index 000000000..e891a0d4c --- /dev/null +++ b/design-system/templates/chicorytv-admin/ChannelEdit.jsx @@ -0,0 +1,335 @@ +// Channel Edit screen — left sub-nav rail (General / Playout / Streaming / Stream +// selection / Music video / Branding) + one focused form pane per section, dense +// label+help / control rows inside a flush Card (mirrors Settings.jsx). A floating +// save bar surfaces validation state and appears only while the draft is dirty. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Card, Button, Badge, ChannelLogo, Input, Select, Switch } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const SECTIONS = [ + { id: "general", label: "General", icon: "Tv", hint: "Number, name, group" }, + { id: "playout", label: "Playout", icon: "ListVideo", hint: "Source & scheduling" }, + { id: "streaming", label: "Streaming", icon: "SlidersHorizontal", hint: "FFmpeg & output" }, + { id: "selection", label: "Stream selection", icon: "AudioLines", hint: "Audio & subtitles" }, + { id: "music", label: "Music video", icon: "Music", hint: "Credits & songs" }, + { id: "branding", label: "Branding", icon: "Image", hint: "Logo & watermark" }, + ]; + + const FFMPEG_PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "Copy (no transcode)"]; + const WATERMARKS = ["(none)", "Station bug — corner", "Station bug — large"]; + const FILLER_PRESETS = ["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]; + const LANGUAGES = ["(none)", "English", "Japanese", "Spanish", "French", "German"]; + const CREDITS_TEMPLATES = ["(none)", "Classic Credits", "Minimal Credits"]; + const STREAM_SELECTORS = ["(none)", "prefer-commentary.py", "anime-dual-audio.py"]; + const MIRROR_CHANNELS = ["(none)", "(1.1) - News 24", "(3.1) - Movie Vault", "(9.1) - Kids Classics"]; + const SLUG_OPTIONS = ["(none)", "0.5 seconds", "1 second", "2 seconds", "3 seconds", "5 seconds"]; + + const SAVED = { + number: "5.1", name: "Retro Cartoons", group: "Entertainment", categories: "Animation, Kids", + isEnabled: true, showInEpg: true, + playoutSource: "Generated", playoutMode: "Continuous", mirrorSourceChannel: "(none)", playoutOffsetHours: "0", + idleBehavior: "Stop On Disconnect", + streamingMode: "MPEG-TS", ffmpegProfile: "1080p H.264", slugSeconds: "1 second", + streamSelectorMode: "Default", preferredAudioLanguage: "English", preferredAudioTitle: "", + preferredSubtitleLanguage: "(none)", subtitleMode: "None", streamSelector: "(none)", + musicVideoCreditsMode: "None", musicVideoCreditsTemplate: "(none)", songVideoMode: "Default", + externalLogoUrl: "", watermark: "(none)", fallbackFiller: "Retro Bumpers", + }; + const PLAYOUT_LOCKED = true; // this channel already has a generated playout + + /* ---------- shared row primitives (mirror Settings.jsx / ChannelEditScreen) ---------- */ + + function Pane({ title, subtitle, children }) { + return ( +
+
+
{title}
+
{subtitle}
+
+ {children} +
+ ); + } + + function Row({ label, help, children, first, control = 300 }) { + return ( +
+
+
{label}
+ {help &&
{help}
} +
+
{children}
+
+ ); + } + + /* ---------- panes ---------- */ + + function GeneralPane({ v, set }) { + return ( + + + + set("number", e.target.value)} + error={v.number.trim() ? null : "Number is required"} /> + + + set("name", e.target.value)} + error={v.name.trim() ? null : "Name is required"} /> + + + set("group", e.target.value)} /> + + + set("categories", e.target.value)} /> + + + set(next ? "isEnabled" : ["isEnabled", "showInEpg"], next)} /> + + + set("showInEpg", next)} /> + + + + ); + } + + function PlayoutPane({ v, set }) { + const isMirror = v.playoutSource === "Mirror"; + return ( + + + + set("mirrorSourceChannel", e.target.value)} options={MIRROR_CHANNELS} /> + + + set("playoutOffsetHours", e.target.value)} + trailing={hours} /> + + + ) : ( + + set("idleBehavior", e.target.value)} options={["Stop On Disconnect", "Keep Running"]} /> + + + set("streamingMode", e.target.value)} + options={["MPEG-TS", "MPEG-TS (Legacy)", "HLS Direct", "HLS Segmenter"]} /> + + + set("slugSeconds", e.target.value)} options={SLUG_OPTIONS} /> + + + + ); + } + + function SelectionPane({ v, set }) { + const isDefault = v.streamSelectorMode === "Default"; + return ( + + + + set("preferredAudioLanguage", e.target.value)} options={LANGUAGES} /> + + + set("preferredAudioTitle", e.target.value)} /> + + + set("subtitleMode", e.target.value)} options={["None", "Forced", "Default", "Any"]} /> + + + ) : ( + + set("musicVideoCreditsMode", e.target.value)} options={["None", "Generate Subtitles"]} /> + + + set("songVideoMode", e.target.value)} options={["Default", "With Progress"]} /> + + + + ); + } + + function BrandingPane({ v, set }) { + const hlsDirect = v.streamingMode === "HLS Direct"; + const urlValid = !v.externalLogoUrl.trim() || /^https?:\/\//i.test(v.externalLogoUrl.trim()); + const previewSrc = v.externalLogoUrl.trim() || undefined; + return ( + + + +
+ + +
+
+ + set("externalLogoUrl", e.target.value)} + error={urlValid ? null : "Enter a valid http(s) URL"} /> + + + set("fallbackFiller", e.target.value)} options={FILLER_PRESETS} /> + +
+
+ ); + } + + const PANES = { + general: GeneralPane, playout: PlayoutPane, streaming: StreamingPane, + selection: SelectionPane, music: MusicPane, branding: BrandingPane, + }; + + /* ---------- screen ---------- */ + + function ChannelEdit() { + const [section, setSection] = React.useState("general"); + const [saved, setSaved] = React.useState(SAVED); + const [draft, setDraft] = React.useState(SAVED); + const [saving, setSaving] = React.useState(false); + const [justSaved, setJustSaved] = React.useState(false); + + const set = (key, val) => { + setJustSaved(false); + if (Array.isArray(key)) { + setDraft((d) => ({ ...d, [key[0]]: val, [key[1]]: val ? d[key[1]] : false })); + } else { + setDraft((d) => ({ ...d, [key]: val })); + } + }; + + const nameValid = Boolean(draft.name.trim()); + const numberValid = Boolean(draft.number.trim()); + const urlValid = !draft.externalLogoUrl.trim() || /^https?:\/\//i.test(draft.externalLogoUrl.trim()); + const valid = nameValid && numberValid && urlValid; + const dirty = JSON.stringify(draft) !== JSON.stringify(saved); + + const PaneCmp = PANES[section]; + + return ( +
+ {/* sub-nav rail */} +
+ {SECTIONS.map((s) => { + const active = s.id === section; + return ( + + ); + })} +
+ + {/* pane */} +
+
+ +
+ + {/* floating save bar */} + {(dirty || justSaved) && ( +
+
+ {justSaved ? ( + + + Channel saved + + ) : ( + + Unsaved changes + {(!nameValid || !numberValid) && Name and number are required} + {!urlValid && Logo URL must be http(s)} + + + + + )} +
+
+ )} +
+
+ ); + } + + window.CTVChannelEdit = ChannelEdit; +})(); diff --git a/design-system/templates/chicorytv-admin/Collections.jsx b/design-system/templates/chicorytv-admin/Collections.jsx new file mode 100644 index 000000000..6f4980a9d --- /dev/null +++ b/design-system/templates/chicorytv-admin/Collections.jsx @@ -0,0 +1,183 @@ +// Collections screen — manual vs. smart collection management, a flush +// browse list per tab, and a drill-in "manage items" detail pane for a +// manual collection (list + reorder-ready detail, mirroring the live SPA). +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Switch } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const MANUAL = [ + { id: 1, name: "Halloween Marathon", custom: true, count: 18 }, + { id: 2, name: "Saturday Morning Cartoons", custom: true, count: 42 }, + { id: 3, name: "Best of Kurosawa", custom: false, count: 11 }, + { id: 4, name: "90s Music Videos", custom: false, count: 63 }, + { id: 5, name: "Rainy Day Comfort Movies", custom: true, count: 9 }, + ]; + + const SMART = [ + { id: 1, name: "Recent Action Movies", query: 'genre:"action" AND released:2015-2026', count: 27 }, + { id: 2, name: "80s Synthpop", query: 'genre:"synthpop" AND released:1980-1989', count: 14 }, + { id: 3, name: "Unwatched Documentaries", query: 'genre:"documentary" AND played:false', count: 38 }, + ]; + + const ITEMS = [ + { id: 1, title: "The Cat Concerto", type: "Episode" }, + { id: 2, title: "Puss Gets the Boot", type: "Episode" }, + { id: 3, title: "Jerry and the Lion", type: "Episode" }, + { id: 4, title: "The Night Before Halloween", type: "Movie" }, + { id: 5, title: "Trick or Treat House", type: "Movie" }, + { id: 6, title: "Season 3", type: "Season" }, + { id: 7, title: "Hocus Pocus", type: "Movie" }, + { id: 8, title: "The Great Pumpkin", type: "Episode" }, + ]; + + const rowStyle = (first) => ({ + display: "flex", + alignItems: "center", + gap: 10, + padding: "0 var(--pad-cell-x)", + height: 46, + borderTop: first ? "none" : "1px solid var(--border-hairline)", + }); + + const flushMain = { flex: 1, minWidth: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }; + + function Segmented({ view, setView, counts }) { + return ( +
+ {[{ v: "manual", l: "Manual", c: counts.manual }, { v: "smart", l: "Smart", c: counts.smart }].map((o) => { + const on = o.v === view; + return ( + + ); + })} +
+ ); + } + + function ManualItemsPane({ collection, onBack }) { + const [reordering, setReordering] = React.useState(false); + return ( +
+
+ + {collection.name} + + {collection.custom && !reordering && ( + + )} + +
+ +
+ + {ITEMS.map((item, i) => ( +
+ + {item.title} + {item.type} + {reordering ? ( + + + + + ) : ( + + )} +
+ ))} +
+
+ +
+ {reordering ? ( + + {ITEMS.length} items +
+ + +
+
+ ) : ( + Showing {ITEMS.length} of {collection.count} + )} +
+
+ ); + } + + function Collections() { + const [view, setView] = React.useState("manual"); + const [selected, setSelected] = React.useState(null); + + if (selected) { + return setSelected(null)} />; + } + + return ( +
+
+ + + {view === "manual" ? ( + + ) : ( + + )} +
+ +
+ {view === "manual" ? ( + + {MANUAL.map((c, i) => ( +
+ + + {c.count} items + + setSelected(c)}> + + +
+ ))} +
+ ) : ( + + {SMART.map((c, i) => ( +
+ +
+
{c.name}
+
{c.query}
+
+ {c.count} matches + + +
+ ))} +
+ )} + +
+ + + Multi-collections, rerun collections and playlists aren't available here yet — manage them in the Classic UI for now. + +
+
+
+ ); + } + + window.CTVCollections = Collections; +})(); diff --git a/design-system/templates/chicorytv-admin/Dashboard.jsx b/design-system/templates/chicorytv-admin/Dashboard.jsx index aeb96a21d..93d780061 100644 --- a/design-system/templates/chicorytv-admin/Dashboard.jsx +++ b/design-system/templates/chicorytv-admin/Dashboard.jsx @@ -1,145 +1,194 @@ -// Dashboard — spec 4.1: On-air-now cards, stat tiles, Health panel, -// Recent activity, collapsible Release notes. +// Dashboard screen — at-a-glance stat row, on-air-now card grid, and a +// system-health panel. Mirrors DashboardScreen.tsx: a success state (stat +// row + two-column grid) plus the loading/error states the live screen +// falls back to while the API request is in flight or fails. (function () { const NS = window.ChicoryTVDesignSystem_eb3b61; - const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, IconButton } = NS; + const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, Button, Spinner } = NS; const Ico = window.Ico; - const D = window.CTV_DATA; - - const toneColor = { accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" }; const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; - // health status -> icon + color - const HEALTH = { - pass: { icon: "Check", color: "var(--status-ok)", label: "Pass" }, - info: { icon: "Info", color: "var(--ctv-accent)", label: "Info" }, - warning: { icon: "TriangleAlert", color: "var(--status-warn)", label: "Warning" }, - fail: { icon: "CircleX", color: "var(--status-error)", label: "Fail" }, - }; + const STATS = [ + { label: "Channels", value: "24", icon: }, + { label: "Active playouts", value: "22", icon: }, + { label: "On air", value: "4", icon: }, + { label: "Libraries", value: "7", icon: }, + ]; - function OnAirCard({ ch, program, pct, remain }) { + const ON_AIR = [ + { num: "1.1", name: "Toon Classics", title: "Tom & Jerry — The Cat Concerto", pct: 48, remain: 14 }, + { num: "2.1", name: "News 24", title: "World Tonight — Evening bulletin", pct: 34, remain: 27 }, + { num: "4.2", name: "Retro Radio", title: "80s Block — Synthpop hour", pct: 51, remain: 19 }, + { num: "9.1", name: "Kids Corner", title: "Sesame Street — Episode 4102", pct: 82, remain: 6 }, + ]; + + // status mirrors ErsatzTV.Application/Health/Mapper.cs: pass | fail | warn | info + const HEALTH = [ + { title: "FFmpeg", detail: "7.1.1 detected on PATH", status: "pass" }, + { title: "Media sources", detail: "5 of 5 libraries reachable", status: "pass" }, + { title: "Transcode folder", detail: "/config/transcode is writable", status: "pass" }, + { title: "HDHomeRun tuners", detail: "2 tuners idle, 2 in use", status: "pass" }, + { title: "Disk space", detail: "/config at 88% capacity", status: "warn" }, + { title: "Plex integration", detail: "Not configured", status: "info" }, + ]; + + function healthIconStatus(status) { + if (status === "fail") return "error"; + if (status === "warn") return "warn"; + if (status === "info") return "idle"; + return "ok"; + } + + function healthIconName(status) { + const s = healthIconStatus(status); + if (s === "error" || s === "warn") return "TriangleAlert"; + if (s === "idle") return "Info"; + return "Check"; + } + + function summarizeHealth(checks) { + const failed = checks.filter((c) => c.status === "fail").length; + const warned = checks.filter((c) => c.status === "warn").length; + if (failed > 0) return { label: `${failed} failing`, status: "error" }; + if (warned > 0) return { label: `${warned} warning${warned === 1 ? "" : "s"}`, status: "warn" }; + return { label: "Healthy", status: "ok" }; + } + + function OnAirCard({ ch }) { return ( -
-
+
+
-
-
- {ch.num} - {ch.name} -
+
+ {ch.num} + {ch.name}
- + On air
-
{program}
-
- -
- {pct}% - {remain} to next -
+

{ch.title}

+ +
+ {ch.pct}% elapsed + {ch.remain}m to next
); } - function ReleaseNotes() { - const [open, setOpen] = React.useState(false); - const rn = D.releaseNotes; + function HealthPanel() { + const summary = summarizeHealth(HEALTH); return ( -
- - {open && ( -
    - {rn.items.map((it, i) => ( -
  • {it}
  • - ))} -
- )} -
+ System health} + subtitle="Backend health checks" + actions={} + padded={false} + > +
+ {HEALTH.map((h, i) => { + const rowStatus = healthIconStatus(h.status); + const colorVar = { ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)", idle: "var(--text-secondary)" }[rowStatus]; + return ( +
+ + + + {h.title} + {h.detail} + +
+ ); + })} +
+
+ +
+
+ ); + } + + function DashboardLoadingState() { + return ( + +
+ + Loading dashboard +
+
+ ); + } + + function DashboardErrorState() { + return ( + Dashboard unavailable} + subtitle="Live API request failed" + > +
+ API request failed + GET /api/dashboard returned 504 Gateway Timeout +
+
); } function Dashboard() { - const onAir = D.channels.filter((c) => c.live).slice(0, 4); - const progs = [ - { program: "Now: Looney Tunes — Rabbit Fire", pct: 68, remain: "07:41" }, - { program: "Now: World Tonight — Evening bulletin", pct: 34, remain: "19:22" }, - { program: "Now: 80s Block — Synthpop hour", pct: 51, remain: "14:08" }, - { program: "Now: Sesame Street — Episode 4102", pct: 82, remain: "05:12" }, - ]; - const failCount = D.health.filter((h) => h.status === "fail").length; - const warnCount = D.health.filter((h) => h.status === "warning").length; + const [state, setState] = React.useState("success"); + + const StateSwitcher = () => ( +
+ {[{ v: "success", l: "Live data" }, { v: "loading", l: "Loading" }, { v: "error", l: "Error" }].map((o) => { + const on = o.v === state; + return ( + + ); + })} +
+ ); return ( -
- {/* stat tiles */} -
- } /> - } /> - } delta="live" deltaTone="neutral" /> - } /> +
+
+ + + + 4 on air +
-
- {/* On air now */} - Live}> -
- {onAir.map((c, i) => )} -
-
+
+ {state === "loading" && } + {state === "error" && } + {state === "success" && ( +
+
+ {STATS.map((s) => ( + + ))} +
- {/* Health panel */} - - {failCount > 0 && {failCount} fail} - {warnCount > 0 && {warnCount} warn} - }> -
- {D.health.map((h, i) => { - const s = HEALTH[h.status]; - return ( -
- - {h.title} - {h.detail} - +
+ On air now} + subtitle="Current programmes by channel" + actions={{ON_AIR.length} on air} + > +
+ {ON_AIR.map((ch) => )}
- ); - })} -
- -
+
-
- {/* Recent activity */} - -
- {D.activity.map((a, i) => ( -
- - - - {a.text} - {a.time} -
- ))} + +
-
- - {/* Release notes (collapsible, secondary) */} -
- -
+ )}
); } + window.CTVDashboard = Dashboard; })(); diff --git a/design-system/templates/chicorytv-admin/DecoTemplates.jsx b/design-system/templates/chicorytv-admin/DecoTemplates.jsx new file mode 100644 index 000000000..0628286a4 --- /dev/null +++ b/design-system/templates/chicorytv-admin/DecoTemplates.jsx @@ -0,0 +1,204 @@ +// Deco Templates screen — group'd list of named time-of-day schedules ("decos" bound to windows +// across a 24h clock), with a group/name editor + add-content row + ordered items table for the +// detail view. Mirrors DecoTemplatesScreen.tsx's list (Card-per-group, flush rows) and editor +// (group/name card, add-content card, items table) states; a small local toggle shows both here. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Input, Select, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const GROUPS = [ + { + id: 1, + name: "Default", + templates: [ + { id: 101, name: "Standard weekday" }, + { id: 102, name: "Standard weekend" }, + { id: 103, name: "Late night bumper-only" }, + ], + }, + { + id: 2, + name: "Holiday", + templates: [ + { id: 104, name: "Halloween week" }, + { id: 105, name: "Christmas Eve — Christmas Day" }, + ], + }, + { + id: 3, + name: "Overnight", + templates: [{ id: 106, name: "Infomercial block 00:00-06:00" }], + }, + { id: 4, name: "Seasonal (unused)", templates: [] }, + ]; + + const DECO_GROUP_OPTIONS = [ + { label: "Select a deco group…", value: "" }, + { label: "Bumpers", value: "1" }, + { label: "Lower thirds", value: "2" }, + { label: "Watermarks", value: "3" }, + ]; + const DECO_OPTIONS = [ + { label: "Select a deco…", value: "" }, + { label: "Station ID — short", value: "11" }, + { label: "Up next bumper", value: "12" }, + { label: "Weather watermark", value: "13" }, + ]; + const START_TIME_OPTIONS = [ + { label: "00:00", value: "00:00:00" }, + { label: "06:00", value: "06:00:00" }, + { label: "09:00", value: "09:00:00" }, + { label: "18:00", value: "18:00:00" }, + { label: "22:00", value: "22:00:00" }, + ]; + const DURATION_MINUTE_OPTIONS = [0, 5, 15, 30, 45].map((m) => ({ label: String(m), value: String(m) })); + + const DRAFT_ITEMS = [ + { key: "i1", time: "00:00-06:00", deco: "Infomercial block deco" }, + { key: "i2", time: "06:00-09:00", deco: "Morning watermark" }, + { key: "i3", time: "09:00-18:00", deco: "Daytime lower third" }, + { key: "i4", time: "18:00-22:00", deco: "Prime time bumper set" }, + { key: "i5", time: "22:00-24:00", deco: "Late night watermark" }, + ]; + + const flushRow = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 16px", height: 46, borderTop: "1px solid var(--border-hairline)" }; + const rowLabel = { font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }; + const linkBtn = { background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }; + const emptyBox = { padding: "22px 16px", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }; + const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", borderBottom: "1px solid var(--border-hairline)", background: "var(--surface-card)" }; + const td = { padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle", borderTop: "1px solid var(--border-hairline)" }; + const settingsRowMain = { flex: "1 1 auto", minWidth: 0 }; + const settingsRowLabel = { font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }; + const settingsRow = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "0 16px", height: 52, borderTop: "1px solid var(--border-hairline)" }; + + function ViewToggle({ view, setView }) { + return ( +
+ {[{ v: "list", l: "Groups" }, { v: "editor", l: "Editor" }].map((o) => { + const on = o.v === view; + return ( + + ); + })} +
+ ); + } + + function DecoTemplateList({ view, setView }) { + const total = GROUPS.reduce((n, g) => n + g.templates.length, 0); + return ( +
+
+ {total} deco templates + + + +
+ + {GROUPS.map((g) => ( + + + + + +
+ } + > + {g.templates.length === 0 ? ( +
No deco templates in this group.
+ ) : ( + g.templates.map((t, i) => ( +
+ + + + +
+ )) + )} + + ))} +
+ ); + } + + function DecoTemplateEditor({ view, setView }) { + return ( +
+
+ + Default / Edit deco template + + + +
+ + +
+
Deco template group
+
+
+
+
Deco template name
+
+
+
+ + +
+
+
+
+ + + {}} /> + + + + + + patch({ graphicsMode: e.target.value })} /> + + + + + + patch({ useGraphicsDuringFiller: v })} /> + + + + }>Add break content : undefined} + > + + ({ value, label }))} onChange={() => {}} /> + + {selectedBreakItem.collectionType === "Playlist" ? ( + + + {}} /> + + + ) : ( + + {selectedBreakItem.selectionName} + + )} + + )} + + + + patch({ defaultFillerType: e.target.value })} /> + + o.value === draft.defaultFillerType)?.label}> +
+ {draft.defaultFillerName} + {}} /> +
+
+ + patch({ defaultFillerTrimToFit: v })} /> + + + )} +
+ + + + setFilter(e.target.value)} options={["All channels", "Favorites", "News", "Kids", "Movies"]} /> -
-
- 20:00 - - 02:00 -
- Now 20:31 - -
- - {/* grid */} -
-
- {/* time header */} -
-
- {D.epgSlots.map((t, i) => ( -
{t}
- ))} -
- - {/* now marker */} -
- -
- - {/* channel rows */} - {D.epg.map((ch, i) => ( -
- {/* rail */} -
- {ch.num} - - {ch.name} -
- {/* programme track */} -
- {ch.programmes.map((p, j) => )} -
-
- ))} -
-
-
- ); - } - window.CTVEpg = Epg; -})(); diff --git a/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx b/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx new file mode 100644 index 000000000..312d08850 --- /dev/null +++ b/design-system/templates/chicorytv-admin/FFmpegProfiles.jsx @@ -0,0 +1,335 @@ +// FFmpeg Profiles screen — a compact profile list (icon + name + resolution/codec badges + +// copy/delete) that drills into a General/Video/Audio settings-row editor, mirroring the +// ChannelEditScreen form language. Static mockup toggles list ⇄ editor for both states. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const PROFILES = [ + { id: 1, name: "1080p30 — NVENC", resolution: "1920x1080", video: "hevc", audio: "aac" }, + { id: 2, name: "1080p30 — Software", resolution: "1920x1080", video: "h264", audio: "aac" }, + { id: 3, name: "720p30 — VAAPI", resolution: "1280x720", video: "h264", audio: "aac" }, + { id: 4, name: "4K HDR — NVENC AV1", resolution: "3840x2160", video: "av1", audio: "ac3" }, + { id: 5, name: "Retro 480i — Copy", resolution: "720x480", video: "mpeg-2 video", audio: "aac" }, + { id: 6, name: "Music Channel — Audio only", resolution: "1280x720", video: "h264", audio: "aac (latm)" }, + { id: 7, name: "QSV Balanced", resolution: "1920x1080", video: "hevc", audio: "aac" }, + { id: 8, name: "Low-bitrate Mobile", resolution: "854x480", video: "h264", audio: "aac" }, + ]; + + const SCALING_OPTIONS = [ + { label: "Scale and Pad", value: "ScaleAndPad" }, + { label: "Stretch", value: "Stretch" }, + { label: "Crop", value: "Crop" }, + ]; + const PAD_MODE_OPTIONS = [ + { label: "Hardware If Possible", value: "HardwareIfPossible" }, + { label: "Software", value: "Software" }, + ]; + const RESOLUTION_OPTIONS = ["1920x1080", "1280x720", "3840x2160", "854x480", "720x480"]; + const VIDEO_FORMAT_OPTIONS = [ + { label: "h264", value: "H264" }, + { label: "hevc", value: "Hevc" }, + { label: "mpeg-2", value: "Mpeg2Video" }, + { label: "av1", value: "Av1" }, + ]; + const VIDEO_PROFILE_OPTIONS = [ + { label: "main", value: "main" }, + { label: "high", value: "high" }, + { label: "high444p", value: "high444p" }, + ]; + const PRESET_OPTIONS = [{ label: "(none)", value: "" }, { label: "llhp", value: "llhp" }, { label: "llhq", value: "llhq" }]; + const BIT_DEPTH_OPTIONS = [ + { label: "8-bit", value: "EightBit" }, + { label: "10-bit", value: "TenBit" }, + ]; + const HWACCEL_OPTIONS = ["None", "Nvenc", "Qsv", "Vaapi", "Amf", "VideoToolbox"]; + const TONEMAP_OPTIONS = ["Linear", "Clip", "Gamma", "Reinhard", "Mobius", "Hable"]; + const AUDIO_FORMAT_OPTIONS = [ + { label: "aac", value: "Aac" }, + { label: "ac3", value: "Ac3" }, + { label: "aac (latm)", value: "AacLatm" }, + ]; + const LOUDNESS_OPTIONS = [ + { label: "Off", value: "Off" }, + { label: "loudnorm", value: "LoudNorm" }, + ]; + + function Row({ label, help, control = 320, first = false, children }) { + return ( +
+
+
{label}
+ {help &&
{help}
} +
+
{children}
+
+ ); + } + + function ActionBar({ children }) { + return ( +
+ {children} +
+ ); + } + + function ProfileList({ onOpen, onAdd }) { + return ( + + + {PROFILES.length} profiles + + + + +
+ + {PROFILES.map((p, i) => ( +
+ + + {p.resolution} + {`${p.video} / ${p.audio}`} + + onAdd()}> + + + + + + + + +
+ ))} +
+
+
+ ); + } + + function ProfileEditor({ profile, isAdd, onBack }) { + const [normalizeVideo, setNormalizeVideo] = React.useState(true); + const [normalizeAudio, setNormalizeAudio] = React.useState(true); + const [hwaccel, setHwaccel] = React.useState(profile ? (profile.video === "hevc" ? "Nvenc" : "None") : "None"); + const [loudness, setLoudness] = React.useState("Off"); + + return ( + + + + + {isAdd ? "New FFmpeg profile" : "Edit FFmpeg profile"} + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + {(hwaccel === "Vaapi" || hwaccel === "Qsv") && ( + + + + )} + {hwaccel === "Qsv" ? ( + + + + ) : ( + + + + + + + + + + + + + + + + + + + )} + + ) : ( +
+ + + Audio will be copied as-is, including timestamps, which will cause issues with most clients. + +
+ )} +
+
+
+ ); + } + + function FFmpegProfiles() { + const [screen, setScreen] = React.useState({ kind: "list" }); + + return ( +
+ {screen.kind === "list" ? ( + setScreen({ kind: "edit", profile: p })} onAdd={() => setScreen({ kind: "add" })} /> + ) : ( + setScreen({ kind: "list" })} + /> + )} +
+ ); + } + + window.CTVFFmpegProfiles = FFmpegProfiles; +})(); diff --git a/design-system/templates/chicorytv-admin/FillerPresets.jsx b/design-system/templates/chicorytv-admin/FillerPresets.jsx new file mode 100644 index 000000000..468d79091 --- /dev/null +++ b/design-system/templates/chicorytv-admin/FillerPresets.jsx @@ -0,0 +1,146 @@ +// Filler Presets screen — a flat list of reusable pre/mid/post-roll filler +// rules with a slide-in editor (kind/mode/collection form), mirroring the +// live SPA's list⇄editor route pair inside one static mockup. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const PRESETS = [ + { id: 1, name: "Network Bumper — Short", kind: "Pre-Roll", mode: "Count", detail: "3 clips", collection: "Bumpers / Short" }, + { id: 2, name: "Station ID", kind: "Pre-Roll", mode: "Duration", detail: "0:00:15", collection: "Station IDs" }, + { id: 3, name: "Ad Break — Standard", kind: "Mid-Roll", mode: "Count", detail: "2 clips", collection: "Commercials 2024" }, + { id: 4, name: "Ad Break — Long Form", kind: "Mid-Roll", mode: "RandomCount", detail: "1–3 clips", collection: "Commercials 2024" }, + { id: 5, name: "Coming Up Next", kind: "Post-Roll", mode: "Count", detail: "1 clip", collection: "Promos" }, + { id: 6, name: "Sign-Off Reel", kind: "Tail", mode: "—", detail: "fills remainder", collection: "Late Night Bumpers" }, + { id: 7, name: "Pad to Top of Hour", kind: "Mid-Roll", mode: "Pad", detail: "pad 15m", collection: "Filler Loops" }, + { id: 8, name: "Dead Air Fallback", kind: "Fallback", mode: "—", detail: "always available", collection: "Please Stand By" }, + { id: 9, name: "Retro Bumper Pack", kind: "Pre-Roll", mode: "Count", detail: "4 clips", collection: "Retro Bumpers" }, + ]; + + const KIND_TONE = { "Pre-Roll": "accent", "Mid-Roll": "accent", "Post-Roll": "neutral", Tail: "neutral", Fallback: "warn" }; + + const KIND_OPTIONS = ["Pre-Roll", "Mid-Roll", "Post-Roll", "Tail", "Fallback"]; + const MODE_OPTIONS = ["Duration", "Count", "Pad", "Random Count"]; + const PAD_OPTIONS = ["5", "10", "15", "30"]; + const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Artist", "Multi Collection", "Smart Collection", "Playlist"]; + + function Row({ label, help, control = 320, first = false, children }) { + return ( +
+
+
{label}
+ {help &&
{help}
} +
+
{children}
+
+ ); + } + + function FillerPresets() { + const [view, setView] = React.useState("list"); // "list" | "edit" | "add" + const [active, setActive] = React.useState(PRESETS[2]); + const [hover, setHover] = React.useState(null); + const [kind, setKind] = React.useState("Mid-Roll"); + const [fmode, setFmode] = React.useState("Count"); + + const openEdit = (p) => { setActive(p); setKind(p.kind); setFmode(p.mode === "—" ? "Count" : p.mode); setView("edit"); }; + const openAdd = () => { setActive(null); setKind("Pre-Roll"); setFmode("Count"); setView("add"); }; + + const modeLocked = kind === "Tail" || kind === "Fallback"; + + if (view !== "list") { + const isEdit = view === "edit"; + return ( +
+
+ + {isEdit ? "Edit filler preset" : "New filler preset"} + + +
+ +
+ + + + + + setFmode(e.target.value)} options={modeLocked ? [{ value: "", label: "(n/a)" }] : MODE_OPTIONS} /> + + +
+ + + +
+
+ + + + + + + + + +
+
+
+ ); + } + + return ( +
+
+ {PRESETS.length} presets + + +
+ +
+ + {PRESETS.map((p, i) => { + const isHover = hover === p.id; + return ( +
setHover(p.id)} onMouseLeave={() => setHover(null)} + style={{ display: "flex", alignItems: "center", gap: 12, padding: "0 var(--pad-cell-x)", height: 52, borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)", background: isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}> + + + {p.collection} + {p.detail} + {p.mode} + {p.kind} +
+ +
+
+ ); + })} +
+
+
+ ); + } + + window.CTVFillerPresets = FillerPresets; +})(); diff --git a/design-system/templates/chicorytv-admin/Guide.jsx b/design-system/templates/chicorytv-admin/Guide.jsx new file mode 100644 index 000000000..cb7e314bf --- /dev/null +++ b/design-system/templates/chicorytv-admin/Guide.jsx @@ -0,0 +1,181 @@ +// Guide screen — a horizontally-scrolling EPG grid: sticky channel rail down +// the left, a 30-min time ruler across the top, a live "now" marker, and +// programme blocks (current-programme highlight, filler dimming, empty rows). +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Badge, Button, ChannelLogo, Select, StatusDot } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const SLOT_MIN = 30; + const SLOT_W = 156; + const RAIL_W = 196; + const ROW_H = 82; + const HEAD_H = 34; + const WINDOW_HOURS = 13; + const SLOT_COUNT = (WINDOW_HOURS * 60) / SLOT_MIN; + + const WINDOW_START = new Date(2026, 0, 1, 12, 0, 0); + const WINDOW_END = new Date(WINDOW_START.getTime() + WINDOW_HOURS * 60 * 60000); + const NOW = new Date(WINDOW_START.getTime() + 312 * 60000); // 5:12 PM — mid-window + const nowInWindow = NOW >= WINDOW_START && NOW <= WINDOW_END; + const nowMin = (NOW.getTime() - WINDOW_START.getTime()) / 60000; + + function formatTime(d) { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } + function pxFromMin(min) { return (min / SLOT_MIN) * SLOT_W; } + + const slots = Array.from({ length: SLOT_COUNT }, (_, i) => new Date(WINDOW_START.getTime() + i * SLOT_MIN * 60000)); + const nowOffset = pxFromMin(nowMin); + const totalWidth = RAIL_W + slots.length * SLOT_W; + const windowPct = nowInWindow ? Math.round((NOW.getTime() - WINDOW_START.getTime()) / (WINDOW_END.getTime() - WINDOW_START.getTime()) * 100) : 0; + + function buildProgrammes(segments) { + let t = 0; + return segments.map((seg) => { + const startMin = t; + t += seg.dur; + return { title: seg.title, subTitle: seg.sub || null, category: seg.cat || null, filler: !!seg.filler, startMin, stopMin: t }; + }); + } + + const NEWS_TITLES = ["Midday Report", "World Markets", "Capitol Watch", "Global Desk", "Primetime Analysis", "Evening Edition", "Nightly Wrap", "Overnight Desk", "World Tonight", "Morning Preview", "Early Edition", "Business Brief", "Weather & Traffic"]; + + const CHANNELS = [ + { + number: "1.1", name: "ChicoryTV Family", onAir: true, + programmes: buildProgrammes([ + { dur: 90, title: "Tom & Jerry — Marathon", cat: "Kids" }, + { dur: 60, title: "Bluey — Season 3", cat: "Kids", sub: "The Sleepover" }, + { dur: 120, title: "Afternoon Movie: Starlight Express", cat: "Movie" }, + { dur: 90, title: "World Tonight — Evening Bulletin", cat: "News", sub: "Live from the newsroom" }, + { dur: 120, title: "Prime Time Drama Block", cat: "Drama" }, + { dur: 90, title: "Late Night Talk", cat: "Talk" }, + ]), + }, + { + number: "2.1", name: "World News Network", onAir: true, + programmes: buildProgrammes(NEWS_TITLES.map((t) => ({ dur: 60, title: t, cat: "News" }))), + }, + { + number: "3.1", name: "Sitcom Vault", onAir: false, + programmes: buildProgrammes([ + { dur: 30, title: "The Office — Casino Night", cat: "Sitcom" }, + { dur: 30, title: "Parks & Rec — Flu Season", cat: "Sitcom" }, + { dur: 30, title: "Community — Modern Warfare", cat: "Sitcom" }, + { dur: 30, title: "Brooklyn 99 — Halloween", cat: "Sitcom" }, + { dur: 30, title: "The Office — Diversity Day", cat: "Sitcom" }, + { dur: 30, title: "Parks & Rec — The Set Up", cat: "Sitcom" }, + ]), + }, + { + number: "4.2", name: "Synthpop Radio", onAir: true, + programmes: buildProgrammes([ + { dur: 180, title: "Retro Hits Mix", cat: "Music", filler: true }, + { dur: 180, title: "Live DJ Set — Nova", cat: "Music", sub: "Synthwave hour" }, + { dur: 180, title: "Overnight Mix", cat: "Music", filler: true }, + ]), + }, + { + number: "5.1", name: "Kids Zone", onAir: false, + programmes: buildProgrammes([ + { dur: 60, title: "Sesame Street — Episode 4102", cat: "Kids" }, + { dur: 30, title: "Bluey — Marathon", cat: "Kids", sub: "Back-to-back episodes" }, + { dur: 90, title: "Afternoon Cartoons", cat: "Kids" }, + ]), + }, + { number: "9.1", name: "Retro Cartoons", onAir: false, programmes: [] }, + ]; + + function ProgrammeBlock({ p, live }) { + const left = pxFromMin(p.startMin) + 3; + const width = Math.max(24, pxFromMin(p.stopMin) - pxFromMin(p.startMin) - 6); + return ( +
+
+ {live && } + {p.title} +
+ {p.subTitle && {p.subTitle}} + {p.filler ? ( + Filler + ) : p.category && ( + {p.category} + )} +
+ ); + } + + function GuideRow({ channel, index }) { + return ( +
+
+ {channel.number} + + {channel.name} + {channel.onAir && } +
+
+ {channel.programmes.length === 0 ? ( + No programmes in this window + ) : channel.programmes.map((p, i) => ( + = p.startMin && nowMin < p.stopMin} /> + ))} +
+
+ ); + } + + function Guide() { + return ( +
+ {/* toolbar */} +
+
+ + {formatTime(WINDOW_END)} +
+ Now {formatTime(NOW)} + + + +
+ + {/* grid */} +
+
+
+
+
+ {slots.map((slot, i) => ( +
{formatTime(slot)}
+ ))} +
+ + {nowInWindow && ( + + )} + + {CHANNELS.map((channel, i) => )} +
+
+
+
+ ); + } + + window.CTVGuide = Guide; +})(); diff --git a/design-system/templates/chicorytv-admin/ImageBrowser.jsx b/design-system/templates/chicorytv-admin/ImageBrowser.jsx new file mode 100644 index 000000000..0c4d8e972 --- /dev/null +++ b/design-system/templates/chicorytv-admin/ImageBrowser.jsx @@ -0,0 +1,195 @@ +// ImageBrowser screen — a recursive folder tree for image-library slideshow +// sources: expand/collapse subfolders, an inherited/overridden per-folder +// slideshow duration, and quick jump-to-search per folder. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Card, Input, Spinner, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + // ---- mock data: a nested folder tree ---------------------------------- + const TREE = [ + { + id: 1, + name: "Movies", + fullPath: "/media/images/Movies", + imageCount: 120, + subfolderCount: 4, + durationSeconds: null, + expanded: true, + children: [ + { id: 11, name: "Posters", fullPath: "/media/images/Movies/Posters", imageCount: 40, subfolderCount: 0, durationSeconds: 8 }, + { id: 12, name: "Backdrops", fullPath: "/media/images/Movies/Backdrops", imageCount: 35, subfolderCount: 0, durationSeconds: null }, + { id: 13, name: "Logos", fullPath: "/media/images/Movies/Logos", imageCount: 20, subfolderCount: 0, durationSeconds: null }, + { id: 14, name: "Thumbs", fullPath: "/media/images/Movies/Thumbs", imageCount: 25, subfolderCount: 0, durationSeconds: 5 }, + ], + }, + { + id: 2, + name: "TV Shows", + fullPath: "/media/images/TV Shows", + imageCount: 85, + subfolderCount: 6, + durationSeconds: null, + expanded: false, + children: [], + }, + { + id: 3, + name: "Wallpapers", + fullPath: "/media/images/Wallpapers", + imageCount: 300, + subfolderCount: 0, + durationSeconds: 12, + expanded: false, + children: [], + }, + { + id: 4, + name: "Screensaver Rotation", + fullPath: "/media/images/Screensaver Rotation", + imageCount: 54, + subfolderCount: 2, + durationSeconds: null, + expanded: true, + children: [ + { id: 41, name: "Nature", fullPath: "/media/images/Screensaver Rotation/Nature", imageCount: 30, subfolderCount: 0, durationSeconds: null }, + { id: 42, name: "Abstract", fullPath: "/media/images/Screensaver Rotation/Abstract", imageCount: 24, subfolderCount: 0, durationSeconds: 10 }, + ], + }, + { + id: 5, + name: "Concert Photos 2024", + fullPath: "/media/images/Concert Photos 2024", + imageCount: 0, + subfolderCount: 0, + durationSeconds: null, + expanded: false, + children: [], + loading: true, + }, + ]; + + function EditDurationOverlay({ folder, onClose }) { + const [value, setValue] = React.useState(folder.durationSeconds != null ? String(folder.durationSeconds) : ""); + return ( +
+
e.stopPropagation()} + style={{ width: 420, background: "var(--surface-raised)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-lg)", padding: "var(--pad-card)" }} + > +
+ Duration — {folder.name} +
+
+ setValue(e.target.value)} placeholder="Inherited from parent" type="number" fullWidth /> +
+ + + + +
+
+
+
+ ); + } + + function FolderRow({ folder, depth, expandedMap, onToggle, onEdit }) { + const hasChildren = folder.subfolderCount > 0; + const isExpanded = !!expandedMap[folder.id]; + const isLoading = !!folder.loading; + + return ( +
+
+ hasChildren && onToggle(folder.id)}> + {hasChildren ? : } + + + {folder.name} + + + {folder.imageCount} image{folder.imageCount === 1 ? "" : "s"} · {folder.subfolderCount} subfolder{folder.subfolderCount === 1 ? "" : "s"} + + + + + {folder.durationSeconds != null ? `${folder.durationSeconds}s` : "inherited"} + + + onEdit(folder)}> + + + + + + + + +
+ + {isExpanded && isLoading ? ( +
+ + Loading… +
+ ) : null} + + {isExpanded && folder.children && folder.children.length > 0 + ? folder.children.map((child) => ( + + )) + : null} +
+ ); + } + + function ImageBrowser() { + const initialExpanded = {}; + TREE.forEach((f) => { if (f.expanded) initialExpanded[f.id] = true; }); + const [expandedMap, setExpandedMap] = React.useState(initialExpanded); + const [editing, setEditing] = React.useState(null); + + const onToggle = (id) => setExpandedMap((m) => ({ ...m, [id]: !m[id] })); + + return ( +
+ {/* action bar */} +
+ + + Image library folders, indexed for slideshow scheduling + + + +
+ +
+ + {TREE.map((folder) => ( + + ))} + +
+ + {editing ? setEditing(null)} /> : null} +
+ ); + } + + window.CTVImageBrowser = ImageBrowser; +})(); diff --git a/design-system/templates/chicorytv-admin/Libraries.jsx b/design-system/templates/chicorytv-admin/Libraries.jsx new file mode 100644 index 000000000..8dd03ae2a --- /dev/null +++ b/design-system/templates/chicorytv-admin/Libraries.jsx @@ -0,0 +1,265 @@ +// Libraries screen — media-source cards (Local/Plex/Jellyfin/Emby), each holding a list of +// per-library rows with scan status/progress, plus an External Collections card for +// Plex/Jellyfin/Emby collection syncs. Mirrors LibrariesScreen.tsx's card+row composition. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, StatusDot, ProgressBar, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const SOURCES = [ + { + id: 1, + kind: "Local", + name: "Local Media", + address: "Local connection", + icon: "HardDrive", + scanning: false, + libraries: [ + { id: 11, name: "Movies", mediaKind: "Movies", icon: "Clapperboard", itemCount: 2418, lastScan: "2026-07-17T04:12:00Z", scanning: false, percent: null }, + { id: 12, name: "TV Shows", mediaKind: "Shows", icon: "MonitorPlay", itemCount: 5104, lastScan: "2026-07-17T04:31:00Z", scanning: false, percent: null }, + { id: 13, name: "Music Videos", mediaKind: "MusicVideos", icon: "Music", itemCount: 312, lastScan: "2026-07-14T22:05:00Z", scanning: false, percent: null }, + ], + }, + { + id: 2, + kind: "Plex", + name: "Plex Media Server", + address: "192.168.1.40:32400", + icon: "Server", + scanning: true, + libraries: [ + { id: 21, name: "Plex Movies", mediaKind: "Movies", icon: "Clapperboard", itemCount: 1842, lastScan: "2026-07-17T09:02:00Z", scanning: true, percent: 63 }, + { id: 22, name: "Plex TV", mediaKind: "Shows", icon: "MonitorPlay", itemCount: 3390, lastScan: "2026-07-16T18:44:00Z", scanning: false, percent: null }, + ], + }, + { + id: 3, + kind: "Jellyfin", + name: "Jellyfin Home Server", + address: "jellyfin.tblindustries.be", + icon: "Server", + scanning: false, + libraries: [ + { id: 31, name: "Anime", mediaKind: "Shows", icon: "MonitorPlay", itemCount: 861, lastScan: "2026-07-15T11:20:00Z", scanning: false, percent: null }, + { id: 32, name: "Other Videos", mediaKind: "OtherVideos", icon: "Film", itemCount: 204, lastScan: null, scanning: false, percent: null }, + ], + }, + { + id: 4, + kind: "Emby", + name: "Emby Archive", + address: "192.168.1.62:8096", + icon: "Server", + scanning: false, + libraries: [ + { id: 41, name: "Retro Cartoons", mediaKind: "Shows", icon: "MonitorPlay", itemCount: 148, lastScan: "2026-07-10T08:00:00Z", scanning: false, percent: null }, + ], + }, + ]; + + const COLLECTIONS_ROWS = [ + { family: "plex", name: "Plex Media Server", scanning: true }, + { family: "jellyfin", name: "Jellyfin Home Server", scanning: false }, + { family: "emby", name: "Emby Archive", scanning: false }, + ]; + + function fmtTime(iso) { + if (!iso) return null; + const d = new Date(iso); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + function familyLabel(f) { + return f.charAt(0).toUpperCase() + f.slice(1); + } + + const cardShell = { display: "flex", flexDirection: "column", gap: 0, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)", overflow: "hidden" }; + const kindIconWrap = (accent) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "var(--radius-sm)", background: accent ? "var(--ctv-accent-soft)" : "var(--ctv-surface-3)", color: accent ? "var(--ctv-accent)" : "var(--text-secondary)", flex: "0 0 auto" }); + + function SourceHead({ source }) { + const accent = source.kind !== "Local"; + return ( +
+ +
+
+

{source.name}

+ +
+ {source.address} +
+
+ + + + {source.kind !== "Local" && ( + + + + )} +
+
+ ); + } + + function LibraryRow({ library, editable, allowDeepScan }) { + return ( +
+ + + +
+ {library.name} + + {library.mediaKind === "MusicVideos" ? "Music Videos" : library.mediaKind === "OtherVideos" ? "Other Videos" : library.mediaKind} · {library.itemCount.toLocaleString()} items + + + {library.lastScan ? `Last scan ${fmtTime(library.lastScan)}` : "Never scanned"} + +
+
+ {library.scanning ? ( +
+
+ Scanning +
+ ) : ( + Synced + )} + {allowDeepScan && ( + + + + )} + + + + {editable && ( + + + + )} +
+
+ ); + } + + function SourceCard({ source }) { + const totalItems = source.libraries.reduce((s, l) => s + l.itemCount, 0); + const lastScans = source.libraries.map((l) => l.lastScan).filter(Boolean).sort(); + const lastScanLabel = source.scanning ? "Scanning now" : lastScans.length > 0 ? `Last scan ${fmtTime(lastScans[lastScans.length - 1])}` : "Never scanned"; + const isLocal = source.kind === "Local"; + return ( +
+ +
+ {source.libraries.map((lib) => ( + + ))} +
+
+ {lastScanLabel} + {totalItems.toLocaleString()} items · {source.libraries.length} librar{source.libraries.length === 1 ? "y" : "ies"} +
+
+ ); + } + + function ExternalCollections() { + return ( +
+
+ +
+

External Collections

+ Sync Plex, Jellyfin, and Emby collections into ErsatzTV. +
+
+
+ {COLLECTIONS_ROWS.map((row) => ( +
+ + + +
+ {row.name} + {familyLabel(row.family)} collections +
+
+ {row.scanning ? Scanning : Idle} + + + + + + +
+
+ ))} +
+
+ ); + } + + function AddSourceMenu() { + const [open, setOpen] = React.useState(false); + return ( +
+ + {open && ( +
+ {[{ icon: "HardDrive", label: "Local library" }, { icon: "Server", label: "Plex" }, { icon: "Server", label: "Jellyfin" }, { icon: "Server", label: "Emby" }].map((it) => ( + + ))} +
+ )} +
+ ); + } + + function Libraries() { + const totalItems = SOURCES.reduce((sum, s) => sum + s.libraries.reduce((a, l) => a + l.itemCount, 0), 0); + const activeScans = SOURCES.reduce((sum, s) => sum + s.libraries.filter((l) => l.scanning).length, 0); + + return ( +
+
+
+ Sources +

Media Libraries

+

Monitor connected media sources and trigger per-library scans.

+
+ +
+ +
+ {SOURCES.length} sources + {totalItems.toLocaleString()} items + + 0 ? "live" : "ok"} size={7} pulse={activeScans > 0} /> + {activeScans} active scan{activeScans === 1 ? "" : "s"} + +
+ +
+
+ {SOURCES.map((source) => ( + + ))} +
+ +
+ +
+
+
+ ); + } + + window.CTVLibraries = Libraries; +})(); diff --git a/design-system/templates/chicorytv-admin/LocalLibraryEdit.jsx b/design-system/templates/chicorytv-admin/LocalLibraryEdit.jsx new file mode 100644 index 000000000..2cb0916e9 --- /dev/null +++ b/design-system/templates/chicorytv-admin/LocalLibraryEdit.jsx @@ -0,0 +1,284 @@ +// LocalLibraryEdit screen — settings-form detail for one local media library: name + media-kind +// card, a path list (add/remove/move) card, a danger-zone delete card, and a sticky "unsaved +// changes" save bar. Mirrors the create/edit route the SPA renders at /app/libraries/local/:id. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Input, Select } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const MEDIA_KIND_OPTIONS = [ + { label: "Movies", value: "Movies" }, + { label: "Shows", value: "Shows" }, + { label: "Music Videos", value: "MusicVideos" }, + { label: "Other Videos", value: "OtherVideos" }, + { label: "Songs", value: "Songs" }, + { label: "Images", value: "Images" }, + { label: "Remote Streams", value: "RemoteStreams" }, + ]; + + const INITIAL_PATHS = [ + { id: 101, path: "/media/movies/action", count: 412 }, + { id: 102, path: "/media/movies/animated", count: 188 }, + { id: 103, path: "/media/movies/staging", count: 0 }, + ]; + + const OTHER_LIBRARIES = [ + { id: 2, name: "Movies — 4K" }, + { id: 5, name: "Movies — Foreign" }, + ]; + + function Row({ children, first, label, help }) { + return ( +
+
+
{label}
+ {help &&
{help}
} +
+
{children}
+
+ ); + } + + function LocalLibraryEdit() { + const [name, setName] = React.useState("Movies"); + const [paths, setPaths] = React.useState(INITIAL_PATHS); + const [newPath, setNewPath] = React.useState(""); + const [moveTarget, setMoveTarget] = React.useState(null); // path object or null + const [moveDest, setMoveDest] = React.useState(""); + const [deleteOpen, setDeleteOpen] = React.useState(false); + + const dirty = name !== "Movies" || paths !== INITIAL_PATHS; + const nameValid = name.trim() !== ""; + const totalItems = paths.reduce((n, p) => n + p.count, 0); + + const removePath = (p) => setPaths((cur) => cur.filter((x) => x !== p)); + const addPath = () => { + const trimmed = newPath.trim(); + if (!trimmed) return; + setPaths((cur) => [...cur, { id: 0, path: trimmed, count: 0 }]); + setNewPath(""); + }; + + return ( +
+
+
+ + + setName(e.target.value)} size="sm" value={name} /> + + + setNewPath(e.target.value)} placeholder="/media/movies" size="sm" value={newPath} /> +
+ +
+ + + +

+ {totalItems} media items will be removed from the library index. This does not delete files on disk. +

+ +
+
+
+ + {dirty && ( +
+
+ + + Unsaved changes + + {!nameValid && Name is required} + + + +
+
+ )} + + {moveTarget && ( +
+
+
+ Move {moveTarget.path} +
+
+ {}} value="" />} +
+
+ + +
+
+
+ )} + + {deleteOpen && ( +
+
+
+ Delete library +
+
+ This library contains {totalItems} media item(s). Delete it anyway? +
+
+ + +
+
+
+ )} +
+ ); + } + + window.CTVLocalLibraryEdit = LocalLibraryEdit; +})(); diff --git a/design-system/templates/chicorytv-admin/Login.jsx b/design-system/templates/chicorytv-admin/Login.jsx new file mode 100644 index 000000000..426983385 --- /dev/null +++ b/design-system/templates/chicorytv-admin/Login.jsx @@ -0,0 +1,115 @@ +// Login screen — centered auth card with local login form + SSO fallback. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, Input, Card } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + function Login() { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + const canSubmit = username.trim().length > 0 && password.length > 0 && !submitting; + + const handleSubmit = (e) => { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + setError(null); + // Mock: simulate submission delay + setTimeout(() => { + setSubmitting(false); + }, 2000); + }; + + // Mock config: both local login and SSO enabled + const config = { + localLoginEnabled: true, + oidcEnabled: true + }; + + return ( +
+ + {config.localLoginEnabled && ( +
+
+ { + setUsername(e.target.value); + setError(null); + }} + leadingIcon={} + /> + { + setPassword(e.target.value); + setError(null); + }} + leadingIcon={} + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
+ )} + + {config.oidcEnabled && ( +
+ +
+ )} +
+
+ ); + } + + window.CTVLogin = Login; +})(); diff --git a/design-system/templates/chicorytv-admin/Logs.jsx b/design-system/templates/chicorytv-admin/Logs.jsx new file mode 100644 index 000000000..75d7a112d --- /dev/null +++ b/design-system/templates/chicorytv-admin/Logs.jsx @@ -0,0 +1,319 @@ +// Logs screen — recent log entries with sortable timestamp/level columns, free-text filtering, and pagination. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Input, Select, Spinner } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const LEVEL_TONE = { + Debug: 'neutral', + Error: 'error', + Fatal: 'error', + Information: 'accent', + Verbose: 'neutral', + Warning: 'warn' + }; + + const mockEntries = [ + { timestamp: '2026-07-17 14:32:18.547', level: 'Information', message: 'Channel "HBO" started streaming' }, + { timestamp: '2026-07-17 14:31:45.123', level: 'Debug', message: 'FFmpeg process spawned (PID: 2847)' }, + { timestamp: '2026-07-17 14:30:22.891', level: 'Information', message: 'Scheduler triggered collection refresh' }, + { timestamp: '2026-07-17 14:28:55.456', level: 'Warning', message: 'Playout guide generation took 12.3s (expected <10s)' }, + { timestamp: '2026-07-17 14:27:30.234', level: 'Debug', message: 'Cache hit: channel logo for "HBO"' }, + { timestamp: '2026-07-17 14:25:12.789', level: 'Information', message: 'Database backup completed (8.4 MB)' }, + { timestamp: '2026-07-17 14:23:44.321', level: 'Error', message: 'FFmpeg failed for channel "News24" (exit code 1)' }, + { timestamp: '2026-07-17 14:22:15.654', level: 'Warning', message: 'Jellyfin API response time: 523ms' }, + { timestamp: '2026-07-17 14:20:08.987', level: 'Debug', message: 'XMLTV generator skipped 2 inactive channels' }, + { timestamp: '2026-07-17 14:18:30.445', level: 'Information', message: 'M3U playlist regenerated (128 channels)' }, + { timestamp: '2026-07-17 14:16:55.112', level: 'Warning', message: 'Slow query detected: GetChannelGuide (847ms)' }, + { timestamp: '2026-07-17 14:15:22.678', level: 'Debug', message: 'LRU cache evicted 3 stale entries' }, + ]; + + const PAGE_SIZE_OPTIONS = ['25', '50', '100']; + + const th = { + textAlign: "left", + padding: "0 var(--pad-cell-x)", + height: 34, + font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", + letterSpacing: "0.04em", + textTransform: "uppercase", + color: "var(--text-disabled)", + whiteSpace: "nowrap", + position: "sticky", + top: 0, + background: "var(--surface-card)", + borderBottom: "1px solid var(--border-hairline)", + zIndex: 2 + }; + + const td = { + padding: "0 var(--pad-cell-x)", + height: 56, + font: "var(--text-sm)/1.3 var(--font-sans)", + color: "var(--text-primary)", + verticalAlign: "middle" + }; + + function Logs() { + const [filterInput, setFilterInput] = React.useState(''); + const [filter, setFilter] = React.useState(''); + const [pageNum, setPageNum] = React.useState(0); + const [pageSize, setPageSize] = React.useState(50); + const [sortField, setSortField] = React.useState('timestamp'); + const [sortDirection, setSortDirection] = React.useState('desc'); + const [isLoading, setIsLoading] = React.useState(false); + + // Mock: filter entries + let filtered = mockEntries; + if (filter) { + const lowerFilter = filter.toLowerCase(); + filtered = filtered.filter(e => + e.level.toLowerCase().includes(lowerFilter) || + e.message.toLowerCase().includes(lowerFilter) + ); + } + + // Mock: sort entries + const sorted = [...filtered].sort((a, b) => { + let cmp = 0; + if (sortField === 'timestamp') { + cmp = a.timestamp.localeCompare(b.timestamp); + } else if (sortField === 'level') { + cmp = a.level.localeCompare(b.level); + } + return sortDirection === 'desc' ? -cmp : cmp; + }); + + const totalCount = sorted.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const start = pageNum * pageSize; + const entries = sorted.slice(start, start + pageSize); + + const handleFilterChange = (e) => { + setFilterInput(e.target.value); + setPageNum(0); + setFilter(e.target.value.trim()); + }; + + const handlePageSizeChange = (e) => { + setPageNum(0); + setPageSize(Number(e.target.value)); + }; + + const handleRefresh = () => { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 500); + }; + + const toggleSort = (field) => { + setPageNum(0); + if (field === sortField) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('asc'); + } + }; + + return ( +
+ {/* action bar */} +
+ } + onChange={handleFilterChange} + placeholder="Filter by level or message…" + value={filterInput} + size="md" + /> + + changeKind(e.target.value)} + options={KINDS.map((k) => ({ value: k.slug, label: k.label }))} + fullWidth={false} + style={{ width: 180 }} + /> + setQueryInput(e.target.value)} + placeholder={"Search " + kind.label.toLowerCase() + "…"} + leadingIcon={} + /> + + {kindSlug === "images" && ( + + )} + + +
+ + {/* bulk action bar */} + {selCount > 0 && ( +
+ {selCount} selected + + + + + +
+ )} + +
+ {items.length === 0 ? ( + +
+ {q ? "No " + kind.label.toLowerCase() + " match this search." : "No " + kind.label.toLowerCase() + " found."} +
+
+ ) : ( + +
+ {items.map((it) => ( + setHoverId(it.id)} + onLeave={() => setHoverId((h) => (h === it.id ? null : h))} + onClick={() => (selectMode ? toggleSelect(it.id) : null)} + /> + ))} +
+ +
+ {totalCount} {kind.label.toLowerCase()} + + + Page 1 of {totalPages} + + +
+
+ )} + +
+ + + Browsing indexed library items. Use the search box for a quick per-kind filter, or the global search for results across every media kind. + +
+
+
+ ); + } + + window.CTVMedia = Media; +})(); diff --git a/design-system/templates/chicorytv-admin/MediaDetail.jsx b/design-system/templates/chicorytv-admin/MediaDetail.jsx new file mode 100644 index 000000000..361270545 --- /dev/null +++ b/design-system/templates/chicorytv-admin/MediaDetail.jsx @@ -0,0 +1,328 @@ +// Media Detail screen — a fanart-backed hero (poster + title/plot/actions), a chip-stack of +// metadata rows, and a paged child grid (seasons/episodes/music videos) for hierarchical kinds. +// One shell serves Movie / Show / Season / Artist detail; a segmented switcher previews all four. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Tag, Card, Spinner, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const KINDS = { + movie: { + label: "Movie", + back: "Back", + title: "The Cat Concerto", + subtitle: "1947", + plot: + "Tom the cat, playing a piano concerto on stage, is disturbed by Jerry the mouse, who lives inside the piano. The two engage in a slapstick battle that becomes part of the performance itself.", + poster: true, + fanart: true, + state: null, + path: "/media/movies/tom-and-jerry/the-cat-concerto (1947)/The Cat Concerto.mkv", + chips: [ + { label: "Genres", values: ["Animation", "Short", "Comedy"] }, + { label: "Tags", values: ["Theatrical Short", "Best Original Score"] }, + { label: "Studios", values: ["MGM"] }, + { label: "Content Ratings", values: ["G"] }, + { label: "Languages", values: ["English"] }, + { label: "Directors", values: ["William Hanna", "Joseph Barbera"] }, + { label: "Writers", values: ["William Hanna", "Joseph Barbera"] }, + ], + actors: [ + { id: 1, name: "Tom (voice)", role: "Cat", thumb: null }, + { id: 2, name: "Jerry (voice)", role: "Mouse", thumb: null }, + ], + actions: ["info", "troubleshoot", "addto"], + grid: null, + }, + show: { + label: "Show", + back: "Back", + title: "Sesame Street", + subtitle: "1969", + plot: + "A pioneering educational program combining puppetry, animation, and live-action sketches to teach preschool-aged children letters, numbers, and social skills, set on a fictional New York City street.", + poster: true, + fanart: true, + state: null, + chips: [ + { label: "Genres", values: ["Children", "Educational"] }, + { label: "Tags", values: ["Muppets", "PBS"] }, + { label: "Studios", values: ["Sesame Workshop"] }, + { label: "Networks", values: ["PBS", "HBO Max"] }, + { label: "Content Ratings", values: ["TV-Y"] }, + { label: "Languages", values: ["English"] }, + ], + actors: [ + { id: 1, name: "Bob McGrath", role: "Bob", thumb: null }, + { id: 2, name: "Loretta Long", role: "Susan", thumb: null }, + { id: 3, name: "Will Lee", role: "Mr. Hooper", thumb: null }, + ], + actions: ["addto", "scan"], + grid: { heading: "Seasons", kind: "season", count: 54, page: 1, pages: 1 }, + }, + season: { + label: "Season", + back: "Back to show", + title: "Sesame Street — Season 42", + subtitle: "2011", + plot: null, + poster: true, + fanart: false, + state: null, + chips: [], + actors: [], + actions: ["addto"], + grid: { heading: "Episodes", kind: "episode", count: 26, page: 1, pages: 1 }, + }, + artist: { + label: "Artist", + back: "Back", + title: "Talking Heads", + subtitle: "New York, NY", + plot: + "An American rock band formed in 1975, known for their nervy, angular art-rock sound and David Byrne's distinctive vocal style. Inducted into the Rock and Roll Hall of Fame in 2002.", + poster: true, + fanart: true, + state: null, + chips: [ + { label: "Genres", values: ["New Wave", "Art Rock", "Post-Punk"] }, + { label: "Styles", values: ["Energetic", "Quirky"] }, + { label: "Moods", values: ["Manic", "Paranoid"] }, + { label: "Languages", values: ["English"] }, + ], + actors: [], + actions: ["addto"], + grid: { heading: "Music Videos", kind: "video", count: 8, page: 1, pages: 1 }, + }, + }; + + const CHILD_ITEMS = { + season: Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: `Season ${i + 1}`, sub: `${22 + (i % 4)} episodes` })), + episode: Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: `Episode ${4090 + i}`, sub: `${20 + (i % 3)}:15` })), + video: [ + { id: 1, title: "Once in a Lifetime", sub: "4:19" }, + { id: 2, title: "Burning Down the House", sub: "3:59" }, + { id: 3, title: "This Must Be the Place", sub: "4:57" }, + { id: 4, title: "Road to Nowhere", sub: "4:19" }, + { id: 5, title: "Psycho Killer", sub: "4:47" }, + { id: 6, title: "And She Was", sub: "4:03" }, + { id: 7, title: "Wild Wild Life", sub: "4:19" }, + { id: 8, title: "Life During Wartime", sub: "3:41" }, + ], + }; + + const STREAMS = [ + { i: 0, kind: "Video", codec: "h264", lang: "—", title: "—" }, + { i: 1, kind: "Audio", codec: "aac", lang: "eng", title: "Stereo" }, + { i: 2, kind: "Subtitle", codec: "subrip", lang: "eng", title: "English SDH" }, + ]; + + function ChipRow({ label, values }) { + if (!values || values.length === 0) return null; + return ( +
+ {label} +
+ {values.map((v) => {v})} +
+
+ ); + } + + function PosterThumb({ size, radius, hue }) { + return ( +
+
+
+ ); + } + + function ActorsRow({ actors }) { + if (!actors || actors.length === 0) return null; + return ( +
+

Cast

+
+ {actors.map((a) => ( +
+
+
{a.name}
+ {a.role ?
{a.role}
: null} +
+ ))} +
+
+ ); + } + + function ChildGrid({ heading, kind, count, page, pages }) { + const items = CHILD_ITEMS[kind] || []; + return ( +
+

{heading}

+
+ {items.map((item, idx) => { + const hue = (idx * 47 + kind.length * 13) % 360; + return ( +
+
+
+
+ +
+
+
{item.title}
+
{item.sub}
+
+ ); + })} +
+
+ {count} + + + Page {page} of {pages} + = pages}> +
+
+ ); + } + + function MediaInfoDialog({ open, onClose }) { + if (!open) return null; + return ( +
+
e.stopPropagation()} style={{ width: 720, maxWidth: "90vw", maxHeight: "85vh", overflow: "auto", background: "var(--surface-raised)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-lg)" }}> +
+ Media Info + +
+
+
+
Kind
+
Movie
+
Library
+
Movies (Plex)
+
Duration
+
7:37
+
Resolution
+
1920×1080
+
Aspect Ratio
+
1.78:1
+
+
+

Streams

+
+ + + + {["#", "Kind", "Codec", "Language", "Title"].map((h) => ( + + ))} + + + + {STREAMS.map((s) => ( + + + + + + + + ))} + +
{h}
{s.i}{s.kind}{s.codec}{s.lang}{s.title}
+
+
+
+
+
+ ); + } + + function MediaDetail() { + const [kind, setKind] = React.useState("movie"); + const [infoOpen, setInfoOpen] = React.useState(false); + const d = KINDS[kind]; + + const Segmented = () => ( +
+ {Object.keys(KINDS).map((k) => { + const on = k === kind; + return ( + + ); + })} +
+ ); + + return ( +
+ {/* preview switcher — not part of the live screen, lets this mockup demo all 4 detail kinds */} +
+ + + Preview kind + + +
+ +
+
+ {d.fanart && ( +
+ )} + +
+ {d.poster ? : null} +
+

{d.title}

+ {d.subtitle ?
{d.subtitle}
: null} + {d.plot ?

{d.plot}

: null} + {d.state === "FileNotFound" && ( +
+ File not found +
+ )} +
+ {d.actions.includes("info") && } + {d.actions.includes("troubleshoot") && } + {d.actions.includes("scan") && ( + + + + + )} + {d.actions.includes("addto") && } +
+
+
+ + {d.chips.length > 0 && ( + +
+ {d.chips.map((c) => )} +
+
+ )} + + + + {d.path ?
{d.path}
: null} + + {d.grid && } +
+
+ + setInfoOpen(false)} /> +
+ ); + } + + window.CTVMediaDetail = MediaDetail; +})(); diff --git a/design-system/templates/chicorytv-admin/MultiCollections.jsx b/design-system/templates/chicorytv-admin/MultiCollections.jsx new file mode 100644 index 000000000..6bb0f0129 --- /dev/null +++ b/design-system/templates/chicorytv-admin/MultiCollections.jsx @@ -0,0 +1,243 @@ +// Multi-Collections screen — a flush-row list of named groups-of-collections +// (manual + smart, each optionally "scheduled as group"), with a full-bleed +// editor pane for building one out. List + editor share the same action-bar +// shell, mirroring Channels.jsx's density. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Badge, Card, Input, Select, Switch, Spinner } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const MULTI_COLLECTIONS = [ + { id: 1, name: "Prime Time Sitcoms", items: 4 }, + { id: 2, name: "Weekend Cartoon Block", items: 6 }, + { id: 3, name: "Late Night Anthology", items: 3 }, + { id: 4, name: "Holiday Specials Rotation", items: 8 }, + { id: 5, name: "80s Action Marathon", items: 2 }, + { id: 6, name: "Kids After School", items: 5 }, + ]; + + const EDIT_ITEMS = [ + { kind: "manual", name: "Friends", scheduleAsGroup: true }, + { kind: "manual", name: "Seinfeld", scheduleAsGroup: true }, + { kind: "manual", name: "The Office (US)", scheduleAsGroup: false }, + { kind: "smart", name: "Sitcoms — Unwatched, Short", scheduleAsGroup: false }, + ]; + + const flushRow = (first) => ({ + display: "flex", + alignItems: "center", + gap: 12, + padding: "0 var(--pad-cell-x)", + height: 52, + borderTop: first ? "none" : "1px solid var(--border-hairline)", + }); + + function ListRow({ mc, first, onOpen }) { + const [hover, setHover] = React.useState(false); + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + + + + {mc.items} item{mc.items === 1 ? "" : "s"} + + + + +
+ ); + } + + function EditItemRow({ item, first }) { + const [checked, setChecked] = React.useState(item.scheduleAsGroup); + return ( +
+ + + {item.name} + + {item.kind === "smart" ? "Smart" : "Manual"} + + + + +
+ ); + } + + function EditorPane({ existing, onBack }) { + const [name, setName] = React.useState(existing ? existing.name : ""); + const [colSel, setColSel] = React.useState(""); + const [smartSel, setSmartSel] = React.useState(""); + const items = existing ? EDIT_ITEMS : []; + + const collectionOptions = [ + { label: "Select a collection…", value: "" }, + { label: "Action Movies", value: "1" }, + { label: "Classic Sitcoms", value: "2" }, + { label: "Documentaries", value: "3" }, + { label: "Kids — Shorts", value: "4" }, + ]; + const smartOptions = [ + { label: "Select a smart collection…", value: "" }, + { label: "Unwatched, Short", value: "1" }, + { label: "Recently Added (30d)", value: "2" }, + ]; + + return ( +
+
+ + + {existing ? "Edit multi-collection" : "New multi-collection"} + + + +
+ +
+ + setName(e.target.value)} placeholder="Multi-collection name" /> + +
+ setSmartSel(e.target.value)} options={smartOptions} fullWidth /> + +
+
+ + + {items.length === 0 ? ( +
+ No collections added yet. +
+ ) : ( + items.map((item, i) => ) + )} +
+
+
+ ); + } + + function MultiCollections() { + const [editing, setEditing] = React.useState(null); // null | { kind: "new" } | { kind: "edit", item } + const [demo, setDemo] = React.useState("populated"); // populated | empty | loading — mockup state switcher + + if (editing) { + return setEditing(null)} />; + } + + if (demo === "loading") { + return ( +
+ + Loading multi-collections… +
+ ); + } + + const rows = demo === "empty" ? [] : MULTI_COLLECTIONS; + + return ( +
+
+ {rows.length} multi-collections + + {/* mockup-only state switcher — not part of the live screen */} +
+ {[{ v: "populated", l: "Populated" }, { v: "empty", l: "Empty" }, { v: "loading", l: "Loading" }].map((o) => { + const on = o.v === demo; + return ( + + ); + })} +
+ +
+ +
+ + {rows.length === 0 ? ( +
+ No multi-collections yet. +
+ ) : ( + rows.map((mc, i) => setEditing({ kind: "edit", item: mc })} />) + )} +
+
+
+ ); + } + + window.CTVMultiCollections = MultiCollections; +})(); diff --git a/design-system/templates/chicorytv-admin/PathReplacementsEdit.jsx b/design-system/templates/chicorytv-admin/PathReplacementsEdit.jsx new file mode 100644 index 000000000..d48efe820 --- /dev/null +++ b/design-system/templates/chicorytv-admin/PathReplacementsEdit.jsx @@ -0,0 +1,146 @@ +// PathReplacementsEdit screen — two-column path-replacement editor with list and detail form. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, Input, Card, Spinner } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + // Mock data: a few realistic path replacements for Jellyfin + const mockReplacements = [ + { id: 1, remotePath: "/mnt/media/movies", localPath: "/var/lib/jellyfin/media/movies" }, + { id: 2, remotePath: "/mnt/media/tv", localPath: "/var/lib/jellyfin/media/tv" }, + { id: 3, remotePath: "/media/archive", localPath: "/srv/archive" }, + ]; + + function PathReplacementsEdit() { + const [selectedId, setSelectedId] = React.useState(1); + const [isDirty, setIsDirty] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + + const selected = mockReplacements.find((r) => r.id === selectedId); + + const handleRemoteChange = (val) => { + setIsDirty(true); + }; + + const handleLocalChange = (val) => { + setIsDirty(true); + }; + + const handleSave = () => { + setIsSaving(true); + setTimeout(() => { + setIsSaving(false); + setIsDirty(false); + }, 500); + }; + + const handleDiscard = () => { + setIsDirty(false); + }; + + return ( +
+ {/* Header section */} +
+
+
Jellyfin path replacements
+

Edit path replacements

+

Map Jellyfin media paths onto the paths ErsatzTV can reach.

+
+
+ +
+
+ + {/* Main content: two-column grid */} +
+ {/* Left panel: list of replacements */} +
+ {/* Section header */} +
+ Replacements + +
+ + {/* List of replacements */} +
+ {mockReplacements.map((row) => { + const isActive = row.id === selectedId; + return ( +
setSelectedId(row.id)} + style={{ + display: "flex", + flexDirection: "column", + gap: 4, + padding: "12px 12px", + marginBottom: 8, + borderRadius: "var(--radius-md)", + background: isActive ? "var(--ctv-accent-soft)" : "var(--ctv-surface)", + border: isActive ? "1px solid var(--ctv-accent)" : "1px solid var(--border-control)", + cursor: "pointer", + transition: "background var(--dur-fast), border-color var(--dur-fast)" + }} + > + {row.remotePath} +
+ + {row.localPath} +
+
+ ); + })} +
+
+ + {/* Right panel: edit form */} +
+ {selected ? ( + Edit replacement} padded> +
+ handleRemoteChange(e.target.value)} + placeholder="/mnt/media" + error={selected.remotePath.trim() ? null : "Jellyfin Path is required"} + /> + handleLocalChange(e.target.value)} + placeholder="/var/lib/jellyfin/media" + error={selected.localPath.trim() ? null : "Local Path is required"} + /> +
+
+ ) : ( +
Select or add a path replacement
+ )} +
+
+ + {/* Footer toolbar */} +
+ + +
+
+ ); + } + + window.CTVPathReplacementsEdit = PathReplacementsEdit; +})(); diff --git a/design-system/templates/chicorytv-admin/PlaybackTroubleshooting.jsx b/design-system/templates/chicorytv-admin/PlaybackTroubleshooting.jsx new file mode 100644 index 000000000..0f7e41044 --- /dev/null +++ b/design-system/templates/chicorytv-admin/PlaybackTroubleshooting.jsx @@ -0,0 +1,191 @@ +// Playback Troubleshooting screen — a diagnostic console for previewing a single media item or +// channel through the live segmenter: settings form → Play → an embedded HLS preview + a +// speed-colored FFmpeg log tail. Mirrors PlaybackTroubleshootingScreen.tsx's media-mode layout. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, Badge, StatusDot, Card, Input, Select, Checkbox, Spinner, Tooltip } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + + const PROFILE_OPTIONS = [ + { value: "1", label: "Direct — Copy" }, + { value: "2", label: "1080p — NVENC H.264" }, + { value: "3", label: "720p — Software x264" }, + { value: "4", label: "4K HDR — NVENC HEVC" }, + ]; + + const SELECTOR_OPTIONS = [ + { value: "", label: "(none)" }, + { value: "best", label: "best" }, + { value: "eng-5.1", label: "eng-5.1" }, + { value: "commentary", label: "commentary" }, + ]; + + const SUBTITLE_OPTIONS = [ + { value: "", label: "(none)" }, + { value: "14201", label: "14201: eng - English (SRT)" }, + { value: "14202", label: "14202: eng - English SDH (SRT)" }, + { value: "14203", label: "14203: fre - French (PGS)" }, + ]; + + const WATERMARKS = [ + { id: 1, name: "Corner bug — ChicoryTV" }, + { id: 2, name: "Lower-third — Now Playing" }, + ]; + + const GRAPHICS = [ + { id: 1, name: "Rating card overlay" }, + { id: 2, name: "Bumper — Up Next" }, + ]; + + const LOG_TAIL = `[info] libx264 @ 0x5590c4a0: profile High, level 4.0, 4:2:0, 8-bit +[info] Stream mapping: + Stream #0:0 -> #0:0 (h264 (native) -> h264 (libx264)) + Stream #0:1 -> #0:1 (aac (native) -> aac (native)) +frame= 1240 fps= 62 q=23.0 size= 18432kB time=00:00:51.66 bitrate=2924.1kbits/s speed=1.29x +frame= 1480 fps= 61 q=23.0 size= 22016kB time=00:01:01.63 bitrate=2925.7kbits/s speed=1.27x +frame= 1720 fps= 60 q=23.0 size= 25600kB time=00:01:11.63 bitrate=2926.9kbits/s speed=1.25x +frame= 1960 fps= 60 q=23.0 size= 29184kB time=00:01:21.63 bitrate=2927.8kbits/s speed=1.26x`; + + function Row({ label, help, children }) { + return ( +
+
+
{label}
+ {help &&
{help}
} +
+
{children}
+
+ ); + } + + function CheckboxList({ items, selected, onToggle }) { + return ( +
+ {items.map((it) => ( + onToggle(it.id)} label={it.name} /> + ))} +
+ ); + } + + function PlaybackTroubleshooting() { + const [profile, setProfile] = React.useState("2"); + const [selector, setSelector] = React.useState(""); + const [subtitle, setSubtitle] = React.useState("14201"); + const [watermarks, setWatermarks] = React.useState([1]); + const [graphics, setGraphics] = React.useState([]); + const [fromStart, setFromStart] = React.useState(false); + const [seek, setSeek] = React.useState(2745); + const [phase, setPhase] = React.useState("running"); // idle | starting | running + + const toggle = (arr, setArr, id) => setArr(arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id]); + const isBusy = phase !== "idle"; + const speed = 1.26; + const speedColor = speed < 0.9 ? "var(--status-error)" : speed > 1.1 ? "var(--status-ok)" : "var(--status-warn)"; + + return ( +
+ {/* action bar */} +
+ + + + + + Media #14582 + +
+ +
+ {/* success notice */} +
+ + FFmpeg troubleshooting process exited successfully + + +
+ + +
+ + setSelector(e.target.value)} options={SELECTOR_OPTIONS} fullWidth /> + + + setSeek(Number(e.target.value) || 0)} fullWidth /> + +
+
+ + +
+ + {isBusy && ( + + + {phase === "starting" ? "Starting…" : "Transcoding…"} + + )} +
+
+
+ + HLS preview stream +
+
+ +
+
00:45:47 / 01:56:32
+
+
+ + + Logs + + Speed: {speed}x + + + } + > +