Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76f8a939c |
Executable
+58
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
backgroundColor: "var(--surface-card)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: "var(--pad-card)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12
|
||||
}}>
|
||||
{title && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<CardFrame
|
||||
title="Machine API Key"
|
||||
subtitle="This key authenticates MCP and external REST clients. The browser no longer uses it — you sign in with your account instead."
|
||||
>
|
||||
{/* Success callout */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "12px 14px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--ctv-ok-soft)",
|
||||
border: "1px solid var(--status-ok)",
|
||||
color: "var(--text-primary)"
|
||||
}}>
|
||||
<Ico n="ShieldCheck" s={15} color="var(--status-ok)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
Give this key to an MCP server or external REST client to let it call this server's API.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Machine key field */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<label style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-secondary)" }}>
|
||||
Machine key
|
||||
</label>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--ctv-bg-sunken)",
|
||||
border: "1px solid var(--border-control)",
|
||||
fontFamily: "var(--font-mono)"
|
||||
}}>
|
||||
<Ico n="KeyRound" s={14} color="var(--text-disabled)" />
|
||||
<code style={{
|
||||
flex: 1,
|
||||
overflowX: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
font: "var(--text-sm)/1.3 var(--font-mono)",
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
background: "none"
|
||||
}}>
|
||||
{revealed ? MACHINE_KEY : "••••••••••••••••••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
startIcon={revealed ? <Ico n="EyeOff" s={14} /> : <Ico n="Eye" s={14} />}
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
>
|
||||
{revealed ? "Hide" : "Reveal"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
startIcon={copied ? <Ico n="Check" s={14} /> : <Ico n="Copy" s={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<CardFrame
|
||||
title="Local admin password"
|
||||
subtitle="Change the password for your local admin account."
|
||||
>
|
||||
{error && (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "12px 14px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--ctv-warn-soft)",
|
||||
border: "1px solid var(--status-warn)",
|
||||
color: "var(--text-primary)"
|
||||
}}>
|
||||
<Ico n="TriangleAlert" s={15} color="var(--status-warn)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)" }}>
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form fields */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: error ? 12 : 0 }}>
|
||||
<Input
|
||||
label="Current password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => {
|
||||
setCurrentPassword(e.target.value);
|
||||
setJustSaved(false);
|
||||
}}
|
||||
leadingIcon={<Ico n="Lock" s={14} />}
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
setJustSaved(false);
|
||||
}}
|
||||
leadingIcon={<Ico n="Lock" s={14} />}
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginTop: 16 }}>
|
||||
{justSaved && (
|
||||
<span style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)",
|
||||
color: "var(--status-ok)"
|
||||
}}>
|
||||
<Ico n="Check" s={14} />
|
||||
Password changed.
|
||||
</span>
|
||||
)}
|
||||
{!justSaved && <span />}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
startIcon={<Ico n="Check" s={14} />}
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={saving}
|
||||
>
|
||||
{saving ? "Changing…" : "Change password"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKey() {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
gap: 16,
|
||||
padding: "20px",
|
||||
overflow: "auto"
|
||||
}}>
|
||||
<MachineKeyCard />
|
||||
{mockAuthMethod === "local" && <LocalPasswordCard />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVApiKey = ApiKey;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "contents" }}>
|
||||
<div style={{ padding: "8px var(--pad-cell-x)", font: "var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", borderTop: "1px solid var(--border-hairline)" }}>{label}</div>
|
||||
<div style={{ padding: "8px var(--pad-cell-x)", font: "var(--text-sm)/1.3 var(--font-mono)", color: "var(--text-primary)", borderTop: "1px solid var(--border-hairline)" }}>{value === "" ? "—" : value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
|
||||
{/* playout picker */}
|
||||
<div style={actionbar}>
|
||||
<Select value={playoutId} onChange={() => {}} options={PLAYOUTS} style={{ minWidth: 280 }} />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
<Ico n="Stethoscope" s={14} color="var(--text-faint)" /> Diagnostic view
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* block filter */}
|
||||
<div style={{ ...actionbar, borderBottom: "none", paddingBottom: 0 }}>
|
||||
<Input
|
||||
value={blockFilter}
|
||||
onChange={(e) => setBlockFilter(e.target.value)}
|
||||
placeholder="Search for blocks…"
|
||||
leadingIcon={<Ico n="Search" s={14} />}
|
||||
style={{ minWidth: 280 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* blocks table */}
|
||||
<div style={section}>
|
||||
<div style={tableWrap}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Block</th>
|
||||
<th style={{ ...th, width: 120 }}>Minutes</th>
|
||||
<th style={{ ...th, width: 140, textAlign: "right" }}>History</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g}>
|
||||
<tr>
|
||||
<td colSpan={3} style={{ padding: "0 var(--pad-cell-x)", height: 30, background: "var(--ctv-bg-sunken)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.05em", textTransform: "uppercase", color: "var(--text-secondary)" }}>{g || "Ungrouped"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{gmap[g].map((b) => {
|
||||
const isSel = selectedBlockId === b.id;
|
||||
const isHover = hoverBlock === b.id;
|
||||
return (
|
||||
<tr
|
||||
key={b.id}
|
||||
onMouseEnter={() => 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)" }}
|
||||
>
|
||||
<td style={{ ...td, font: isSel ? "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)" : td.font }}>{b.name}</td>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{b.minutes}</td>
|
||||
<td style={{ ...td, textAlign: "right" }}>
|
||||
{b.id >= 0 && (
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="History" s={14} />} onClick={() => setSelectedBlockId(b.id)}>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* history table */}
|
||||
{selectedBlock && (
|
||||
<div style={section}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0" }}>
|
||||
<strong style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
History — {selectedBlock.name}
|
||||
</strong>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Select value={pageSize} onChange={(e) => setPageSize(e.target.value)} options={["10", "25", "50", "100"]} style={{ width: 96 }} size="sm" />
|
||||
</div>
|
||||
|
||||
<div style={tableWrap}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Start</th>
|
||||
<th style={th}>Finish</th>
|
||||
<th style={th}>Key</th>
|
||||
<th style={th}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{HISTORY.map((entry, i) => {
|
||||
const isSel = selectedHistoryId === entry.id;
|
||||
const isHover = hoverRow === entry.id;
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
onClick={() => setSelectedHistoryId(entry.id)}
|
||||
onMouseEnter={() => setHoverRow(entry.id)}
|
||||
onMouseLeave={() => setHoverRow(null)}
|
||||
style={{ cursor: "pointer", borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)", background: isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
|
||||
>
|
||||
<td style={{ ...td, whiteSpace: "nowrap" }}>{formatDateTime(entry.when)}</td>
|
||||
<td style={{ ...td, whiteSpace: "nowrap" }}>{formatDateTime(entry.finish)}</td>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{entry.key}</td>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{entry.details}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span><span style={mono}>{HISTORY.length}</span> entries</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<IconButton size="sm" title="Previous page" disabled><Ico n="ChevronLeft" s={15} /></IconButton>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)" }}>Page <span style={mono}>1</span> of <span style={mono}>1</span></span>
|
||||
<IconButton size="sm" title="Next page" disabled><Ico n="ChevronRight" s={15} /></IconButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* decoded history-row detail panel */}
|
||||
{selectedBlock && (
|
||||
<div style={{ ...section, paddingBottom: 20 }}>
|
||||
<Card padded title="History details" subtitle="Decoded from the selected history row">
|
||||
{details ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "180px 1fr" }}>
|
||||
<DetailRow label="Playback Order" value={details.playbackOrder} />
|
||||
<DetailRow label="Collection Type" value={details.collectionType} />
|
||||
<DetailRow label="Name" value={details.name} />
|
||||
<DetailRow label="Media Item Type" value={details.mediaItemType} />
|
||||
<DetailRow label="Media Item Title" value={details.mediaItemTitle} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "8px 0", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
Select a history row above to decode its stored key.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVBlockPlayoutTroubleshooting = BlockPlayoutTroubleshooting;
|
||||
})();
|
||||
@@ -0,0 +1,281 @@
|
||||
// Blocks screen — grouped block library (list view) drilling into a block editor
|
||||
// (metadata + ordered item table + item detail form), mirroring BlocksScreen.tsx's
|
||||
// two-mode layout (list vs. editor) 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 GROUPS = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Anime Blocks",
|
||||
blocks: [
|
||||
{ id: 101, name: "Toonami Late Night", minutes: 180 },
|
||||
{ id: 102, name: "Saturday Morning Anime", minutes: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "News & Talk",
|
||||
blocks: [
|
||||
{ id: 201, name: "Morning Briefing", minutes: 60 },
|
||||
{ id: 202, name: "Evening Roundup", minutes: 90 },
|
||||
{ id: 203, name: "Late Edition", minutes: 45 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Nostalgia",
|
||||
blocks: [{ id: 301, name: "90s Sitcom Hour", minutes: 60 }],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Holiday Specials",
|
||||
blocks: [],
|
||||
},
|
||||
];
|
||||
|
||||
const ITEMS = [
|
||||
{ key: "i1", collection: "Toonami Bumps & Filler", type: "Collection", order: "Shuffle", epg: true, watermarks: false },
|
||||
{ key: "i2", collection: "Cowboy Bebop", type: "Television Show", order: "Season, Episode", epg: true, watermarks: false },
|
||||
{ key: "i3", collection: "Trigun", type: "Television Show", order: "Season, Episode", epg: true, watermarks: true },
|
||||
{ key: "i4", collection: "Late Night Interstitials", type: "Smart Collection", order: "Random", epg: false, watermarks: false },
|
||||
{ key: "i5", collection: "\"toonami OR bebop\"", type: "Search Query", order: "Chronological", epg: true, watermarks: false },
|
||||
];
|
||||
|
||||
const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Smart Collection", "Search Query"];
|
||||
const PLAYBACK_ORDER_OPTIONS = ["Chronological", "Shuffle", "Random", "Random Rotation"];
|
||||
const WATERMARKS = [{ id: 1, name: "Bottom-right bug" }, { id: 2, name: "Corner logo (translucent)" }];
|
||||
const GRAPHICS = [{ id: 1, name: "Now Playing lower third" }, { id: 2, name: "Up Next bumper" }];
|
||||
|
||||
function Row({ label, children, control = 360, first }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
padding: "12px var(--pad-cell-x)",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 6, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlushRow({ children, first }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0 var(--pad-cell-x)",
|
||||
height: 46,
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectMock({ options, selected }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{options.map((o) => (
|
||||
<Checkbox key={o.id} checked={selected.includes(o.id)} label={o.name} onChange={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockList({ onOpenBlock }) {
|
||||
const totalBlocks = GROUPS.reduce((n, g) => n + g.blocks.length, 0);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{totalBlocks} blocks</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div style={{ flex: 1, maxWidth: 360 }}>
|
||||
<Input leadingIcon={<Ico n="Search" s={14} />} placeholder="Search for blocks…" value="" onChange={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{GROUPS.map((g) => (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New block</Button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{g.blocks.length === 0 ? (
|
||||
<div style={{ padding: "18px var(--pad-cell-x)", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>No blocks in this group.</div>
|
||||
) : (
|
||||
g.blocks.map((b, i) => (
|
||||
<FlushRow key={b.id} first={i === 0}>
|
||||
<Ico n="Boxes" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBlock}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--ctv-accent)",
|
||||
}}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
<Badge tone="neutral">{b.minutes} min</Badge>
|
||||
<Tooltip label="Copy block"><IconButton size="sm" variant="ghost" title="Copy block"><Ico n="Copy" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="Delete block"><IconButton size="sm" variant="ghost" title="Delete block"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</FlushRow>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockEditor({ onBack }) {
|
||||
const [selectedKey, setSelectedKey] = React.useState("i2");
|
||||
const selected = ITEMS.find((it) => it.key === selectedKey) || null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All blocks</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Anime Blocks / Edit block</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Eye" s={14} />}>Preview</Button>
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save block</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card padded={false} title="Block">
|
||||
<Row control={360} first label="Block group">
|
||||
<Input disabled size="sm" value="Anime Blocks" onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={360} label="Block name">
|
||||
<Input size="sm" value="Toonami Late Night" onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={200} label="Duration (hours)">
|
||||
<Input size="sm" type="number" trailing="hours" value="3" onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={200} label="Duration (minutes)">
|
||||
<Select value="0" onChange={() => {}} options={["0", "15", "30", "45"]} />
|
||||
</Row>
|
||||
<Row control={360} label="Stop scheduling block items">
|
||||
<Select value="Before Duration End" onChange={() => {}} options={["Before Duration End", "After Duration End"]} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Block items" actions={<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>}>
|
||||
<div style={{ overflow: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ 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)", borderBottom: "1px solid var(--border-hairline)" }}>Collection</th>
|
||||
<th style={{ 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)", borderBottom: "1px solid var(--border-hairline)" }}>Playback order</th>
|
||||
<th style={{ 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)", borderBottom: "1px solid var(--border-hairline)" }}>Show in EPG</th>
|
||||
<th style={{ 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)", borderBottom: "1px solid var(--border-hairline)" }}>Disable watermarks</th>
|
||||
<th style={{ textAlign: "right", padding: "0 var(--pad-cell-x)", height: 34, borderBottom: "1px solid var(--border-hairline)" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ITEMS.map((it, index) => {
|
||||
const isSel = it.key === selectedKey;
|
||||
return (
|
||||
<tr
|
||||
key={it.key}
|
||||
onClick={() => setSelectedKey(it.key)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isSel ? "var(--ctv-accent-soft)" : "transparent",
|
||||
borderTop: index === 0 ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span>{it.collection}</span>
|
||||
<Badge tone="neutral">{it.type}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{it.order}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={it.epg} onChange={() => {}} />
|
||||
</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={it.watermarks} onChange={() => {}} />
|
||||
</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", gap: 2, justifyContent: "flex-end" }}>
|
||||
<IconButton size="sm" variant="ghost" title="Duplicate"><Ico n="Copy" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Move up" disabled={index === 0}><Ico n="ArrowUp" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Move down" disabled={index === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<Card padded={false} title="Block item">
|
||||
<Row control={360} first label="Collection type">
|
||||
<Select value={selected.type === "Television Show" ? "Television Show" : selected.type} onChange={() => {}} options={COLLECTION_TYPE_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label={selected.type}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Badge tone="accent">{selected.collection}</Badge>
|
||||
<Input size="sm" placeholder="Type to search…" value="" onChange={() => {}} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row control={360} label="Playback order">
|
||||
<Select value={selected.order} onChange={() => {}} options={PLAYBACK_ORDER_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Watermarks">
|
||||
<MultiSelectMock options={WATERMARKS} selected={selected.watermarks ? [1] : []} />
|
||||
</Row>
|
||||
<Row control={360} label="Graphics elements">
|
||||
<MultiSelectMock options={GRAPHICS} selected={[2]} />
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Blocks() {
|
||||
const [mode, setMode] = React.useState("list");
|
||||
return mode === "list" ? <BlockList onOpenBlock={() => setMode("editor")} /> : <BlockEditor onBack={() => setMode("list")} />;
|
||||
}
|
||||
|
||||
window.CTVBlocks = Blocks;
|
||||
})();
|
||||
@@ -10,12 +10,14 @@
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
|
||||
|
||||
// ---- Mock library (shows / movies / music / docs across libraries) --------
|
||||
// ---- Mock library (shows / movies / artists across libraries) -------------
|
||||
// `seasons: true` marks a TelevisionShow with the "browse seasons" drill-in
|
||||
// (issue #180) — lets a specific season be added instead of the whole show.
|
||||
const LIB = [
|
||||
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96 },
|
||||
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96, seasons: true },
|
||||
{ id: "s2", title: "Tom & Jerry", type: "Show", library: "Cartoons", sub: "164 shorts", min: 72 },
|
||||
{ id: "s3", title: "Popeye", type: "Show", library: "Cartoons", sub: "108 shorts", min: 54 },
|
||||
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84 },
|
||||
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84, seasons: true },
|
||||
{ id: "s5", title: "Scooby-Doo", type: "Show", library: "Cartoons", sub: "97 episodes", min: 60 },
|
||||
{ id: "s6", title: "Pink Panther", type: "Show", library: "Cartoons", sub: "124 shorts", min: 48 },
|
||||
{ id: "m1", title: "Blade Runner", type: "Movie", library: "Movies", sub: "1982 · Sci-Fi", min: 117 },
|
||||
@@ -24,27 +26,31 @@
|
||||
{ id: "m4", title: "Metropolis", type: "Movie", library: "Movies", sub: "1927 · Sci-Fi", min: 153 },
|
||||
{ id: "m5", title: "Nosferatu", type: "Movie", library: "Movies", sub: "1922 · Horror", min: 94 },
|
||||
{ id: "t1", title: "The Twilight Zone", type: "Show", library: "TV Shows", sub: "156 episodes", min: 51 },
|
||||
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50 },
|
||||
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50, seasons: true },
|
||||
{ id: "t3", title: "Columbo", type: "Show", library: "TV Shows", sub: "69 episodes", min: 94 },
|
||||
{ id: "t4", title: "Cheers", type: "Show", library: "TV Shows", sub: "275 episodes", min: 24 },
|
||||
{ id: "u1", title: "80s Synthpop", type: "Music", library: "Music Videos", sub: "212 videos", min: 45 },
|
||||
{ id: "u2", title: "90s Alt Rock", type: "Music", library: "Music Videos", sub: "188 videos", min: 45 },
|
||||
{ id: "u3", title: "Chart Countdown", type: "Music", library: "Music Videos", sub: "Top 40", min: 60 },
|
||||
{ id: "d1", title: "Blue Planet", type: "Doc", library: "Documentaries", sub: "8 episodes", min: 50 },
|
||||
{ id: "d2", title: "Planet Earth", type: "Doc", library: "Documentaries", sub: "11 episodes", min: 50 },
|
||||
{ id: "d3", title: "Cosmos", type: "Doc", library: "Documentaries", sub: "13 episodes", min: 60 },
|
||||
{ id: "u1", title: "80s Synthpop", type: "Artist", library: "Music Videos", sub: "212 items", min: 45 },
|
||||
{ id: "u2", title: "90s Alt Rock", type: "Artist", library: "Music Videos", sub: "188 items", min: 45 },
|
||||
{ id: "u3", title: "Chart Countdown", type: "Artist", library: "Music Videos", sub: "Top 40", min: 60 },
|
||||
{ id: "d1", title: "Blue Planet", type: "Movie", library: "Documentaries", sub: "1h 30m", min: 90 },
|
||||
{ id: "d2", title: "Planet Earth", type: "Movie", library: "Documentaries", sub: "50m", min: 50 },
|
||||
{ id: "d3", title: "Cosmos", type: "Movie", library: "Documentaries", sub: "1h 00m", min: 60 },
|
||||
];
|
||||
const LIBRARIES = ["All", "Cartoons", "Movies", "TV Shows", "Music Videos", "Documentaries"];
|
||||
const TYPE_ICON = { Show: "Tv", Movie: "Film", Music: "Music", Doc: "Radio", Multi: "Layers", Smart: "Sparkles", Manual: "FolderTree", Rerun: "Repeat" };
|
||||
// Icon/label per LibraryBrowseMediaType — mirrors web/src/media/mediaKinds.ts TYPE_ICON/TYPE_LABEL
|
||||
// (only Movie/TelevisionShow/Artist are browsable top-level library kinds; Collection/Smart/Multi/
|
||||
// Rerun/Playlist are the "Collections" source).
|
||||
const TYPE_ICON = { Show: "Tv", Movie: "Film", Artist: "Music", Collection: "FolderTree", Multi: "Layers", Smart: "Sparkles", Rerun: "Repeat", Playlist: "ListVideo" };
|
||||
|
||||
// Collections — curated groups (Manual / Smart / Multi / Rerun) addable as a
|
||||
// single lineup entry, so the builder isn't limited to raw library items.
|
||||
const COLLECTIONS = [
|
||||
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
|
||||
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
|
||||
{ id: "c3", title: "Retro Station IDs", type: "Manual", library: "Collection", sub: "Manual · 24 items", min: 12, coll: true },
|
||||
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly",min: 120, coll: true },
|
||||
{ id: "c5", title: "Halloween Marathon", type: "Manual", library: "Collection", sub: "Manual · 9 films", min: 900, coll: true },
|
||||
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
|
||||
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
|
||||
{ id: "c3", title: "Retro Station IDs", type: "Collection", library: "Collection", sub: "24 items", min: 12, coll: true },
|
||||
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly", min: 120, coll: true },
|
||||
{ id: "c5", title: "Halloween Marathon", type: "Collection", library: "Collection", sub: "9 films", min: 900, coll: true },
|
||||
{ id: "c6", title: "Late Night Bumpers", type: "Playlist", library: "Collection", sub: "24 items", min: 18, coll: true },
|
||||
];
|
||||
|
||||
// Channel Templates — bundle the clunky technical config; one is pre-selected.
|
||||
@@ -59,23 +65,43 @@
|
||||
sets: ["1080p H.264", "HLS Segmenter", "Shuffle", "Always on", "Bumpers + ads"] },
|
||||
];
|
||||
|
||||
// Advanced (~30-field) override set, grouped. Values shown as template-inherited.
|
||||
// Advanced field set — grouped + labeled to match the real ChannelBuilder's
|
||||
// ADVANCED_KEYS (web/src/builder/ChannelBuilder.tsx): 22 counted fields
|
||||
// (20 override keys + the dedicated playbackOrder/playoutMode toggles),
|
||||
// plus Group/Categories which ride along in Behavior but aren't counted.
|
||||
// kind picks the override-mode control: "select" | "input" | "switch".
|
||||
const ADV = [
|
||||
{ group: "Streaming", fields: [
|
||||
["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920×1080"],
|
||||
["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"],
|
||||
{ k: "FFmpeg profile", v: "1080p NVENC", kind: "select", opts: ["1080p NVENC", "1080p x264 veryfast", "720p x264 fast", "4K HEVC NVENC"] },
|
||||
{ k: "Streaming mode", v: "HLS Direct", kind: "select", opts: ["MPEG-TS", "HLS Direct", "HLS Segmenter", "MPEG-TS Hybrid"] },
|
||||
{ k: "Transcode mode", v: "On Demand", kind: "select", opts: ["On Demand"] },
|
||||
]},
|
||||
{ group: "Filler", fields: [
|
||||
["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"],
|
||||
["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"],
|
||||
{ k: "Fallback filler", v: "Static Bumper", kind: "select", opts: ["Static Bumper", "Test Pattern", "None"] },
|
||||
{ k: "Pre-roll filler", v: "Static Bumper", kind: "select", opts: ["Static Bumper", "Retro Promo", "None"] },
|
||||
{ k: "Mid-roll filler", v: "Ad Break", kind: "select", opts: ["Ad Break", "None"] },
|
||||
{ k: "Post-roll filler", v: "None", kind: "select", opts: ["Outro Card", "None"] },
|
||||
{ k: "Watermark", v: "ChicoryTV Bug", kind: "select", opts: ["ChicoryTV Bug", "None"] },
|
||||
]},
|
||||
{ group: "Playback", fields: [
|
||||
["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"],
|
||||
["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"],
|
||||
{ k: "Playback order", v: "Chronological", kind: "select", opts: ["Chronological", "Shuffle", "Random", "ShuffleInOrder", "SeasonEpisode", "Marathon"] },
|
||||
{ k: "Preferred audio language", v: "eng", kind: "input" },
|
||||
{ k: "Preferred subtitle language", v: "Not set", kind: "input" },
|
||||
{ k: "Preferred audio title", v: "Not set", kind: "input" },
|
||||
{ k: "Subtitle mode", v: "Forced", kind: "select", opts: ["None", "Forced", "Default", "Any"] },
|
||||
{ k: "Stream selector mode", v: "Default", kind: "select", opts: ["Default", "Custom", "Troubleshooting"] },
|
||||
{ k: "Stream selector", v: "Not set", kind: "input" },
|
||||
]},
|
||||
{ group: "Behavior", fields: [
|
||||
["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"],
|
||||
["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"],
|
||||
{ k: "Playout mode", v: "Always on", kind: "select", opts: ["Always on", "On demand"] },
|
||||
{ k: "Music video credits", v: "None", kind: "select", opts: ["None", "GenerateSubtitles"] },
|
||||
{ k: "Song video mode", v: "Default", kind: "select", opts: ["Default", "WithProgress"] },
|
||||
{ k: "Idle behavior", v: "Stop on disconnect", kind: "select", opts: ["Stop on disconnect", "Keep running"] },
|
||||
{ k: "Fixed start time", v: "Flexible", kind: "select", opts: ["Strict", "Flexible"] },
|
||||
{ k: "Random start point", v: "Off", kind: "switch" },
|
||||
{ k: "Shuffle schedule items", v: "Off", kind: "switch" },
|
||||
{ k: "Group", v: "ChicoryTV", kind: "input", uncounted: true },
|
||||
{ k: "Categories", v: "", kind: "input", uncounted: true, viewOk: false },
|
||||
]},
|
||||
];
|
||||
|
||||
@@ -167,7 +193,9 @@
|
||||
}
|
||||
|
||||
// ---- Library card ----------------------------------------------------------
|
||||
function LibCard({ item, added, compact, onAdd, onDragStart, onDragEnd }) {
|
||||
// `item.seasons` -> a TelevisionShow shows a "browse seasons" affordance
|
||||
// (issue #180) so a single season can be added instead of the whole show.
|
||||
function LibCard({ item, added, compact, onAdd, onSeasons, onDragStart, onDragEnd }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div draggable onDragStart={onDragStart} onDragEnd={onDragEnd} onDoubleClick={onAdd} title="Double-click or drag to add" className="ctv-press"
|
||||
@@ -178,6 +206,11 @@
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.15 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{item.type} · <span style={mono}>{item.sub}</span></div>
|
||||
</div>
|
||||
{item.seasons && (
|
||||
<IconButton size="sm" title="Browse seasons" onClick={(e) => { e.stopPropagation(); onSeasons && onSeasons(); }}>
|
||||
<Ico n="FolderTree" s={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="sm" title={added ? "Added" : "Add to lineup"} onClick={onAdd}>
|
||||
<Ico n={added ? "Check" : "Plus"} s={15} color={added ? "var(--action-primary)" : undefined} />
|
||||
</IconButton>
|
||||
@@ -189,6 +222,13 @@
|
||||
style={{ position: "relative", cursor: "grab" }}>
|
||||
<div style={{ position: "relative", borderRadius: "var(--radius-sm)", boxShadow: added ? "0 0 0 2px var(--action-primary)" : "none" }}>
|
||||
<Poster item={item} w="100%" h={150} />
|
||||
{item.seasons && (
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); onSeasons && onSeasons(); }}
|
||||
style={{ position: "absolute", left: 6, bottom: 6, display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 7px", borderRadius: "var(--radius-pill)", cursor: "pointer",
|
||||
background: "rgba(11,13,17,.78)", border: "1px solid var(--border-control)", color: "var(--text-primary)", font: "var(--weight-medium) 10px/1 var(--font-sans)", backdropFilter: "blur(2px)" }}>
|
||||
<Ico n="FolderTree" s={11} />Seasons
|
||||
</button>
|
||||
)}
|
||||
{added && (
|
||||
<div style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--action-primary)", color: "var(--text-on-accent)", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)", animation: "ctv-pop 260ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
<Ico n="Check" s={14} />
|
||||
@@ -286,9 +326,11 @@
|
||||
// ---- Advanced disclosure — one-click Override (edit) or View defaults ------
|
||||
function Advanced({ templateName, scrollerRef }) {
|
||||
const [mode, setMode] = React.useState("closed"); // closed | view | override
|
||||
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; });
|
||||
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach((f) => { o[f.k] = f.v; })); return o; });
|
||||
const override = mode === "override";
|
||||
const count = ADV.reduce((n, g) => n + g.fields.length, 0);
|
||||
// Counted fields mirror the real fieldCount = ADVANCED_KEYS.length + 2
|
||||
// (playbackOrder + playoutMode); Group/Categories ride along uncounted.
|
||||
const count = ADV.reduce((n, g) => n + g.fields.filter((f) => !f.uncounted).length, 0);
|
||||
const fieldsRef = React.useRef(null);
|
||||
const rootRef = React.useRef(null);
|
||||
const [maxH, setMaxH] = React.useState(0);
|
||||
@@ -342,16 +384,33 @@
|
||||
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
|
||||
{override ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k]) => (
|
||||
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => setVals((s) => ({ ...s, [k]: e.target.value }))} />
|
||||
))}
|
||||
{g.fields.map((f) => {
|
||||
if (f.kind === "switch") {
|
||||
const on = vals[f.k] === "On";
|
||||
return (
|
||||
<div key={f.k} style={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 2px" }}>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{f.k}</span>
|
||||
<Switch checked={on} onChange={(next) => setVals((s) => ({ ...s, [f.k]: next ? "On" : "Off" }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (f.kind === "select") {
|
||||
return (
|
||||
<Select key={f.k} size="sm" label={f.k} value={vals[f.k]} options={f.opts}
|
||||
onChange={(e) => setVals((s) => ({ ...s, [f.k]: e.target.value }))} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input key={f.k} size="sm" label={f.k} value={vals[f.k]} onChange={(e) => setVals((s) => ({ ...s, [f.k]: e.target.value }))} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k, v]) => (
|
||||
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
|
||||
{g.fields.filter((f) => f.kind !== "switch" && f.viewOk !== false).map((f) => (
|
||||
<div key={f.k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{f.k}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(vals[f.k]) ? mono : {}) }}>{vals[f.k]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* 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. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Plus" s={19} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>New Channel</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library — <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></div>
|
||||
</div>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library — <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Tooltip placement="bottom" label={canCreate ? "Creates the channel, schedule & playout" : "Name the channel and add at least one item"}>
|
||||
<Button variant="primary" disabled={!canCreate} startIcon={<Ico n="Check" s={15} />}>Create Channel</Button>
|
||||
@@ -461,6 +519,7 @@
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{filtered.map((item) => (
|
||||
<LibCard key={item.id} item={item} compact added={addedIds.has(item.id)} onAdd={() => add(item)}
|
||||
onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined}
|
||||
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
|
||||
))}
|
||||
</div>
|
||||
@@ -468,6 +527,7 @@
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(104px,1fr))", gap: 10 }}>
|
||||
{filtered.map((item) => (
|
||||
<LibCard key={item.id} item={item} added={addedIds.has(item.id)} onAdd={() => add(item)}
|
||||
onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined}
|
||||
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
|
||||
))}
|
||||
</div>
|
||||
@@ -611,6 +671,34 @@
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Seasons drill-in — expands a TelevisionShow into its seasons so one
|
||||
season can be added instead of the whole show (issue #180). */}
|
||||
{seasonsFor && (
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 40, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,7,9,.6)" }}
|
||||
onClick={() => setSeasonsFor(null)}>
|
||||
<div onClick={(e) => 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)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Seasons — {seasonsFor.title}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<IconButton size="sm" title="Close" onClick={() => setSeasonsFor(null)}><Ico n="X" s={15} /></IconButton>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 10, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{["Season 1", "Season 2", "Season 3", "Specials"].map((s) => (
|
||||
<div key={s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "6px 8px", borderRadius: "var(--radius-sm)" }}>
|
||||
<Poster item={{ title: s, type: seasonsFor.type }} w={40} h={54} mini />
|
||||
<div style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{s}</div>
|
||||
<IconButton size="sm" title="Add to lineup"><Ico n="Plus" s={15} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<Button variant="secondary" onClick={() => setSeasonsFor(null)}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ maxWidth: 720, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
|
||||
<div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, help, children, first, control = 300 }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- panes ---------- */
|
||||
|
||||
function GeneralPane({ v, set }) {
|
||||
return (
|
||||
<Pane title="General" subtitle="Identity shown in the lineup, guide and IPTV playlist.">
|
||||
<Card padded={false}>
|
||||
<Row first control={200} label="Number" help="Displayed channel number, e.g. 5 or 5.1.">
|
||||
<Input size="sm" fullWidth value={v.number} onChange={(e) => set("number", e.target.value)}
|
||||
error={v.number.trim() ? null : "Number is required"} />
|
||||
</Row>
|
||||
<Row label="Name" help="Channel display name.">
|
||||
<Input size="sm" fullWidth value={v.name} onChange={(e) => set("name", e.target.value)}
|
||||
error={v.name.trim() ? null : "Name is required"} />
|
||||
</Row>
|
||||
<Row label="Group" help="Groups channels in clients that support it.">
|
||||
<Input size="sm" fullWidth value={v.group} onChange={(e) => set("group", e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Categories" help="Comma-separated list of categories.">
|
||||
<Input size="sm" fullWidth placeholder="News, Sports" value={v.categories} onChange={(e) => set("categories", e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Enabled" help="Whether this channel is served to clients.">
|
||||
<Switch size="sm" checked={v.isEnabled} onChange={(next) => set(next ? "isEnabled" : ["isEnabled", "showInEpg"], next)} />
|
||||
</Row>
|
||||
<Row label="Show in EPG" help="Include this channel in the XMLTV guide.">
|
||||
<Switch size="sm" checked={v.showInEpg} disabled={!v.isEnabled} onChange={(next) => set("showInEpg", next)} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutPane({ v, set }) {
|
||||
const isMirror = v.playoutSource === "Mirror";
|
||||
return (
|
||||
<Pane title="Playout" subtitle="Where the channel's content comes from and how it progresses.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Playout source"
|
||||
help={PLAYOUT_LOCKED ? "Cannot be changed once a generated channel has a playout." : "Generated builds its own schedule; Mirror follows another channel."}>
|
||||
<Select size="sm" fullWidth disabled={PLAYOUT_LOCKED} value={v.playoutSource}
|
||||
onChange={(e) => set("playoutSource", e.target.value)} options={["Generated", "Mirror"]} />
|
||||
</Row>
|
||||
{isMirror ? (
|
||||
<React.Fragment>
|
||||
<Row label="Mirror source channel" help="The generated channel this channel mirrors.">
|
||||
<Select size="sm" fullWidth value={v.mirrorSourceChannel} onChange={(e) => set("mirrorSourceChannel", e.target.value)} options={MIRROR_CHANNELS} />
|
||||
</Row>
|
||||
<Row control={160} label="Playout offset" help="Shift the mirrored playout by this many hours.">
|
||||
<Input size="sm" fullWidth type="number" style={mono} value={v.playoutOffsetHours}
|
||||
onChange={(e) => set("playoutOffsetHours", e.target.value)}
|
||||
trailing={<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>hours</span>} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Row label="Playout mode" help="Controls how the generated playout progresses.">
|
||||
<Select size="sm" fullWidth value={v.playoutMode} onChange={(e) => set("playoutMode", e.target.value)} options={["Continuous", "On Demand"]} />
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Idle behavior" help="How the transcoder behaves once all clients disconnect.">
|
||||
<Select size="sm" fullWidth value={v.idleBehavior} onChange={(e) => set("idleBehavior", e.target.value)} options={["Stop On Disconnect", "Keep Running"]} />
|
||||
</Row>
|
||||
<Row label="Transcode mode" help="When the transcoding process is active. Only On Demand is supported.">
|
||||
<Select size="sm" fullWidth disabled value="On Demand" options={["On Demand"]} onChange={() => {}} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingPane({ v, set }) {
|
||||
const hlsDirect = v.streamingMode === "HLS Direct";
|
||||
return (
|
||||
<Pane title="Streaming" subtitle="Output container and the transcoding profile applied to this channel.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Streaming mode" help="Delivery container / protocol served to clients.">
|
||||
<Select size="sm" fullWidth value={v.streamingMode} onChange={(e) => set("streamingMode", e.target.value)}
|
||||
options={["MPEG-TS", "MPEG-TS (Legacy)", "HLS Direct", "HLS Segmenter"]} />
|
||||
</Row>
|
||||
<Row label="FFmpeg profile" help={hlsDirect ? "Not used in HLS Direct mode." : "Transcoding preset for this channel."}>
|
||||
<Select size="sm" fullWidth disabled={hlsDirect} value={v.ffmpegProfile} onChange={(e) => set("ffmpegProfile", e.target.value)} options={FFMPEG_PROFILES} />
|
||||
</Row>
|
||||
<Row label="Slug seconds" help="Black video / silent audio inserted between every playout item.">
|
||||
<Select size="sm" fullWidth value={v.slugSeconds} onChange={(e) => set("slugSeconds", e.target.value)} options={SLUG_OPTIONS} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionPane({ v, set }) {
|
||||
const isDefault = v.streamSelectorMode === "Default";
|
||||
return (
|
||||
<Pane title="Stream selection" subtitle="How audio and subtitle tracks are chosen for playback.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Stream selector mode" help="Default picks tracks by preference; Custom uses a selector script.">
|
||||
<Select size="sm" fullWidth value={v.streamSelectorMode} onChange={(e) => set("streamSelectorMode", e.target.value)} options={["Default", "Custom"]} />
|
||||
</Row>
|
||||
{isDefault ? (
|
||||
<React.Fragment>
|
||||
<Row label="Preferred audio language" help="Blank keeps the file order.">
|
||||
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)} options={LANGUAGES} />
|
||||
</Row>
|
||||
<Row label="Preferred audio title" help="Prefer an audio track whose title contains this text.">
|
||||
<Input size="sm" fullWidth value={v.preferredAudioTitle} onChange={(e) => set("preferredAudioTitle", e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Preferred subtitle language" help="Blank disables preference.">
|
||||
<Select size="sm" fullWidth value={v.preferredSubtitleLanguage} onChange={(e) => set("preferredSubtitleLanguage", e.target.value)} options={LANGUAGES} />
|
||||
</Row>
|
||||
<Row label="Subtitle mode" help="When subtitle tracks are burned in.">
|
||||
<Select size="sm" fullWidth value={v.subtitleMode} onChange={(e) => set("subtitleMode", e.target.value)} options={["None", "Forced", "Default", "Any"]} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Row label="Stream selector" help="Name of a custom stream-selector script in the config folder.">
|
||||
<Select size="sm" fullWidth value={v.streamSelector} onChange={(e) => set("streamSelector", e.target.value)} options={STREAM_SELECTORS} />
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function MusicPane({ v, set }) {
|
||||
const creditsOn = v.musicVideoCreditsMode === "Generate Subtitles";
|
||||
return (
|
||||
<Pane title="Music video" subtitle="Overlays and progress behavior for music video and song content.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Credits mode" help="Generate on-screen credits for music videos.">
|
||||
<Select size="sm" fullWidth value={v.musicVideoCreditsMode} onChange={(e) => set("musicVideoCreditsMode", e.target.value)} options={["None", "Generate Subtitles"]} />
|
||||
</Row>
|
||||
<Row label="Credits template" help="Name of the credits template. Only used when credits are generated.">
|
||||
<Select size="sm" fullWidth disabled={!creditsOn} value={v.musicVideoCreditsTemplate} onChange={(e) => set("musicVideoCreditsTemplate", e.target.value)} options={CREDITS_TEMPLATES} />
|
||||
</Row>
|
||||
<Row label="Song video mode" help="Optional progress bar overlay for song content.">
|
||||
<Select size="sm" fullWidth value={v.songVideoMode} onChange={(e) => set("songVideoMode", e.target.value)} options={["Default", "With Progress"]} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pane title="Branding" subtitle="Channel logo and the overlays applied while streaming.">
|
||||
<Card padded={false}>
|
||||
<Row first control={340} label="Logo" help="Shown in the guide and as the on-screen bug.">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, width: "100%" }}>
|
||||
<ChannelLogo name={v.name} size={48} src={previewSrc} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Upload" s={14} />}>Upload logo</Button>
|
||||
</div>
|
||||
</Row>
|
||||
<Row control={340} label="External logo URL" help="An external image URL wins over an uploaded logo when both are set.">
|
||||
<Input size="sm" fullWidth placeholder="https://example.com/logo.png" value={v.externalLogoUrl}
|
||||
onChange={(e) => set("externalLogoUrl", e.target.value)}
|
||||
error={urlValid ? null : "Enter a valid http(s) URL"} />
|
||||
</Row>
|
||||
<Row label="Watermark" help={hlsDirect ? "Not used in HLS Direct mode." : "Overlay applied to the channel."}>
|
||||
<Select size="sm" fullWidth disabled={hlsDirect} value={v.watermark} onChange={(e) => set("watermark", e.target.value)} options={WATERMARKS} />
|
||||
</Row>
|
||||
<Row label="Fallback filler" help="Plays when the playout has nothing scheduled.">
|
||||
<Select size="sm" fullWidth value={v.fallbackFiller} onChange={(e) => set("fallbackFiller", e.target.value)} options={FILLER_PRESETS} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
|
||||
{/* sub-nav rail */}
|
||||
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
|
||||
{SECTIONS.map((s) => {
|
||||
const active = s.id === section;
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
|
||||
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
|
||||
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* pane */}
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
|
||||
<PaneCmp key={section} v={draft} set={set} />
|
||||
</div>
|
||||
|
||||
{/* floating save bar */}
|
||||
{(dirty || justSaved) && (
|
||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
|
||||
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
{justSaved ? (
|
||||
<React.Fragment>
|
||||
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel saved</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Unsaved changes</span>
|
||||
{(!nameValid || !numberValid) && <Badge tone="neutral">Name and number are required</Badge>}
|
||||
{!urlValid && <Badge tone="neutral">Logo URL must be http(s)</Badge>}
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
|
||||
<Button size="sm" variant="primary" loading={saving} disabled={!valid || saving} startIcon={<Ico n="Check" s={14} />}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
window.setTimeout(() => {
|
||||
setSaving(false);
|
||||
setSaved(draft);
|
||||
setJustSaved(true);
|
||||
window.setTimeout(() => setJustSaved(false), 1800);
|
||||
}, 500);
|
||||
}}>
|
||||
Save changes
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVChannelEdit = ChannelEdit;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "manual", l: "Manual", c: counts.manual }, { v: "smart", l: "Smart", c: counts.smart }].map((o) => {
|
||||
const on = o.v === view;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setView(o.v)}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{o.c}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualItemsPane({ collection, onBack }) {
|
||||
const [reordering, setReordering] = React.useState(false);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All collections</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{collection.name}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{collection.custom && !reordering && (
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ArrowUp" s={14} />} onClick={() => setReordering(true)}>Reorder</Button>
|
||||
)}
|
||||
<Button size="sm" variant="primary" startIcon={<Ico n="Plus" s={14} />} disabled={reordering}>Add items</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{ITEMS.map((item, i) => (
|
||||
<div key={item.id} style={rowStyle(i === 0)}>
|
||||
<Ico n="ListVideo" s={15} color="var(--text-disabled)" />
|
||||
<span style={flushMain}>{item.title}</span>
|
||||
<Badge tone="neutral">{item.type}</Badge>
|
||||
{reordering ? (
|
||||
<React.Fragment>
|
||||
<IconButton size="sm" variant="ghost" title={`Move ${item.title} up`} disabled={i === 0}><Ico n="ArrowUp" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title={`Move ${item.title} down`} disabled={i === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<IconButton size="sm" variant="ghost" title={`Remove ${item.title}`}><Ico n="Trash2" s={14} /></IconButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 20px", borderTop: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
{reordering ? (
|
||||
<React.Fragment>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}><span style={mono}>{ITEMS.length}</span> items</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setReordering(false)}>Cancel</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => setReordering(false)}>Save order</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Showing <span style={mono}>{ITEMS.length}</span> of <span style={mono}>{collection.count}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Collections() {
|
||||
const [view, setView] = React.useState("manual");
|
||||
const [selected, setSelected] = React.useState(null);
|
||||
|
||||
if (selected) {
|
||||
return <ManualItemsPane collection={selected} onBack={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Segmented view={view} setView={setView} counts={{ manual: MANUAL.length, smart: SMART.length }} />
|
||||
<span style={{ flex: 1 }} />
|
||||
{view === "manual" ? (
|
||||
<Button variant="primary" size="sm" startIcon={<Ico n="Plus" s={14} />}>New collection</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" startIcon={<Ico n="Plus" s={14} />}>New smart collection</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
{view === "manual" ? (
|
||||
<Card padded={false}>
|
||||
{MANUAL.map((c, i) => (
|
||||
<div key={c.id} style={rowStyle(i === 0)}>
|
||||
<Ico n="FolderTree" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" onClick={() => setSelected(c)}
|
||||
style={{ ...flushMain, textAlign: "left", background: "none", border: "none", padding: 0, cursor: "pointer", font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{c.name}
|
||||
</button>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{c.count} items</span>
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 7, cursor: "pointer" }} title="Use custom playback order">
|
||||
<Switch checked={c.custom} size="sm" />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Custom order</span>
|
||||
</label>
|
||||
<IconButton size="sm" variant="ghost" title="Manage items" onClick={() => setSelected(c)}><Ico n="ListVideo" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Rename"><Ico n="Pencil" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padded={false}>
|
||||
{SMART.map((c, i) => (
|
||||
<div key={c.id} style={rowStyle(i === 0)}>
|
||||
<Ico n="Sparkles" s={15} color="var(--ctv-accent)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</div>
|
||||
<div style={{ ...mono, marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-mono)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.query}</div>
|
||||
</div>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{c.count} matches</span>
|
||||
<IconButton size="sm" variant="ghost" title="Edit"><Ico n="Pencil" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, marginTop: 16, padding: "12px 14px", borderRadius: "var(--radius-md)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="Info" s={14} color="var(--text-disabled)" />
|
||||
<span style={{ font: "var(--text-xs)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Multi-collections, rerun collections and playlists aren't available here yet — manage them in the Classic UI for now.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVCollections = Collections;
|
||||
})();
|
||||
@@ -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: <Ico n="Tv" s={15} /> },
|
||||
{ label: "Active playouts", value: "22", icon: <Ico n="ListVideo" s={15} /> },
|
||||
{ label: "On air", value: "4", icon: <Ico n="Radio" s={15} /> },
|
||||
{ label: "Libraries", value: "7", icon: <Ico n="Library" s={15} /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: 12, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ display: "grid", gap: 10, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)", padding: 12 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0,1fr) auto", alignItems: "center", gap: 8 }}>
|
||||
<ChannelLogo name={ch.name} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)" }}>{ch.num}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
|
||||
</div>
|
||||
<div style={{ minWidth: 0, display: "grid", gap: 2 }}>
|
||||
<code style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>{ch.num}</code>
|
||||
<strong style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</strong>
|
||||
</div>
|
||||
<StatusDot status="live" />
|
||||
<Badge tone="accent" dot>On air</Badge>
|
||||
</div>
|
||||
<div style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{program}</div>
|
||||
<div>
|
||||
<ProgressBar value={pct} tone="accent" height={4} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
|
||||
<span>{pct}%</span>
|
||||
<span>{remain} to next</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.title}</p>
|
||||
<ProgressBar value={ch.pct} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>
|
||||
<span>{ch.pct}% elapsed</span>
|
||||
<span>{ch.remain}m to next</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseNotes() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const rn = D.releaseNotes;
|
||||
function HealthPanel() {
|
||||
const summary = summarizeHealth(HEALTH);
|
||||
return (
|
||||
<div style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "13px 16px", border: "none", background: "transparent", cursor: "pointer", textAlign: "left" }}>
|
||||
<Ico n="Sparkles" s={15} color="var(--text-secondary)" />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>What's new</span>
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{rn.version} · {rn.date}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={16} color="var(--text-disabled)" />
|
||||
</button>
|
||||
{open && (
|
||||
<ul style={{ margin: 0, padding: "4px 18px 16px 34px", display: "flex", flexDirection: "column", gap: 7 }}>
|
||||
{rn.items.map((it, i) => (
|
||||
<li key={i} style={{ font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{it}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Card
|
||||
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>System health</h2>}
|
||||
subtitle="Backend health checks"
|
||||
actions={<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={14} />}>Refresh health</Button>}
|
||||
padded={false}
|
||||
>
|
||||
<div>
|
||||
{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 (
|
||||
<div key={h.title} style={{ display: "grid", gridTemplateColumns: "auto auto minmax(0,1fr) auto", alignItems: "center", gap: 8, minHeight: 46, padding: "0 12px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ width: 24, height: 24, display: "inline-flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-xs)", background: "var(--surface-selected)", color: colorVar }}>
|
||||
<Ico n={healthIconName(h.status)} s={15} />
|
||||
</span>
|
||||
<strong style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap" }}>{h.title}</strong>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
|
||||
<StatusDot status={rowStatus} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", borderTop: "1px solid var(--border-hairline)", padding: "8px 12px" }}>
|
||||
<StatusDot status={summary.status} label={summary.label} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLoadingState() {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ minHeight: 220, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, color: "var(--text-secondary)" }}>
|
||||
<Spinner size={20} tone="accent" />
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>Loading dashboard</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardErrorState() {
|
||||
return (
|
||||
<Card
|
||||
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Dashboard unavailable</h2>}
|
||||
subtitle="Live API request failed"
|
||||
>
|
||||
<div style={{ display: "grid", gap: 6, border: "1px solid var(--status-error)", borderRadius: "var(--radius-md)", background: "var(--ctv-error-soft)", padding: 12 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--status-error)" }}>API request failed</span>
|
||||
<strong style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>GET /api/dashboard returned 504 Gateway Timeout</strong>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 = () => (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "success", l: "Live data" }, { v: "loading", l: "Loading" }, { v: "error", l: "Error" }].map((o) => {
|
||||
const on = o.v === state;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setState(o.v)}
|
||||
style={{ display: "inline-flex", alignItems: "center", height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* stat tiles */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14 }}>
|
||||
<Card><Stat label="Channels" value="128" icon={<Ico n="Tv" s={15} />} /></Card>
|
||||
<Card><Stat label="Active playouts" value="112" icon={<Ico n="ListVideo" s={15} />} /></Card>
|
||||
<Card><Stat label="Transcodes" value="4" icon={<Ico n="Radio" s={15} />} delta="live" deltaTone="neutral" /></Card>
|
||||
<Card><Stat label="Libraries" value="5" icon={<Ico n="Library" s={15} />} /></Card>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<StateSwitcher />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<StatusDot status="live" size={7} /><span style={mono}>4</span> on air
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
|
||||
{/* On air now */}
|
||||
<Card title="On air now" subtitle={`${onAir.length} channels streaming`} actions={<Badge tone="accent" dot>Live</Badge>}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
{onAir.map((c, i) => <OnAirCard key={c.num} ch={c} {...progs[i]} />)}
|
||||
</div>
|
||||
</Card>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
{state === "loading" && <DashboardLoadingState />}
|
||||
{state === "error" && <DashboardErrorState />}
|
||||
{state === "success" && (
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<section aria-label="At a glance" style={{ display: "grid", gridTemplateColumns: "repeat(4,minmax(0,1fr))", gap: 16 }}>
|
||||
{STATS.map((s) => (
|
||||
<Card key={s.label}><Stat label={s.label} value={s.value} icon={s.icon} /></Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Health panel */}
|
||||
<Card title="System health" padded={false}
|
||||
actions={<span style={{ display: "inline-flex", gap: 6 }}>
|
||||
{failCount > 0 && <Badge tone="error" dot>{failCount} fail</Badge>}
|
||||
{warnCount > 0 && <Badge tone="warn" dot>{warnCount} warn</Badge>}
|
||||
</span>}>
|
||||
<div>
|
||||
{D.health.map((h, i) => {
|
||||
const s = HEALTH[h.status];
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none", cursor: "pointer" }}>
|
||||
<Ico n={s.icon} s={15} color={s.color} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", flex: "0 0 auto" }}>{h.title}</span>
|
||||
<span style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-disabled)", flex: 1, textAlign: "right", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
|
||||
<Ico n="ChevronRight" s={14} color="var(--text-faint)" />
|
||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(0,2fr) minmax(260px,1fr)", gap: 16, alignItems: "start" }}>
|
||||
<Card
|
||||
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>On air now</h2>}
|
||||
subtitle="Current programmes by channel"
|
||||
actions={<Badge tone="accent" dot>{ON_AIR.length} on air</Badge>}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,minmax(0,1fr))", gap: 10 }}>
|
||||
{ON_AIR.map((ch) => <OnAirCard key={ch.num} ch={ch} />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
|
||||
{/* Recent activity */}
|
||||
<Card title="Recent activity" padded={false}>
|
||||
<div>
|
||||
{D.activity.map((a, i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 24, height: 24, flex: "0 0 auto", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: toneColor[a.tone] }}>
|
||||
<Ico n={a.icon} s={13} />
|
||||
</span>
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{a.text}</span>
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)", whiteSpace: "nowrap", marginTop: 2 }}>{a.time}</span>
|
||||
</div>
|
||||
))}
|
||||
<HealthPanel />
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Release notes (collapsible, secondary) */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<ReleaseNotes />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVDashboard = Dashboard;
|
||||
})();
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "list", l: "Groups" }, { v: "editor", l: "Editor" }].map((o) => {
|
||||
const on = o.v === view;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setView(o.v)}
|
||||
style={{ height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecoTemplateList({ view, setView }) {
|
||||
const total = GROUPS.reduce((n, g) => n + g.templates.length, 0);
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Badge tone="neutral">{total} deco templates</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ViewToggle view={view} setView={setView} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
|
||||
</div>
|
||||
|
||||
{GROUPS.map((g) => (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New deco template</Button>
|
||||
<Tooltip label="Delete group">
|
||||
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{g.templates.length === 0 ? (
|
||||
<div style={emptyBox}>No deco templates in this group.</div>
|
||||
) : (
|
||||
g.templates.map((t, i) => (
|
||||
<div key={t.id} style={{ ...flushRow, borderTop: i === 0 ? "none" : flushRow.borderTop }}>
|
||||
<button type="button" style={linkBtn} onClick={() => setView("editor")}>{t.name}</button>
|
||||
<Tooltip label="Delete deco template">
|
||||
<IconButton size="sm" variant="ghost" title="Delete deco template"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecoTemplateEditor({ view, setView }) {
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setView("list")}>All deco templates</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Default / Edit deco template</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ViewToggle view={view} setView={setView} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save deco template</Button>
|
||||
</div>
|
||||
|
||||
<Card padded={false} title="Deco template">
|
||||
<div style={{ ...settingsRow, borderTop: "none" }}>
|
||||
<div style={settingsRowMain}><div style={settingsRowLabel}>Deco template group</div></div>
|
||||
<div style={{ flex: "0 0 360px" }}><Input disabled size="sm" value="Default" fullWidth /></div>
|
||||
</div>
|
||||
<div style={settingsRow}>
|
||||
<div style={settingsRowMain}><div style={settingsRowLabel}>Deco template name</div></div>
|
||||
<div style={{ flex: "0 0 360px" }}><Input size="sm" value="Standard weekday" fullWidth /></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Add content">
|
||||
<div style={{ display: "flex", gap: 12, padding: 16, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div style={{ minWidth: 200 }}><Select label="Deco group" options={DECO_GROUP_OPTIONS} value="" /></div>
|
||||
<div style={{ minWidth: 200 }}><Select label="Deco" options={DECO_OPTIONS} value="" /></div>
|
||||
<div style={{ minWidth: 140 }}><Select label="Start time" options={START_TIME_OPTIONS} value="18:00:00" /></div>
|
||||
<div style={{ width: 90 }}><Input label="Hours" type="number" value="1" /></div>
|
||||
<div style={{ width: 110 }}><Select label="Minutes" options={DURATION_MINUTE_OPTIONS} value="30" /></div>
|
||||
<Button size="sm" disabled startIcon={<Ico n="Plus" s={14} />}>Add</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Deco template items">
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead><tr>
|
||||
<th style={th}>Time</th>
|
||||
<th style={th}>Deco</th>
|
||||
<th style={{ ...th, textAlign: "right" }}></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{DRAFT_ITEMS.map((it) => (
|
||||
<tr key={it.key}>
|
||||
<td style={{ ...td, ...mono, width: 130 }}>{it.time}</td>
|
||||
<td style={td}>{it.deco}</td>
|
||||
<td style={{ ...td, width: 60, textAlign: "right" }}>
|
||||
<Tooltip label="Remove"><IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecoTemplates() {
|
||||
const [view, setView] = React.useState("list");
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{view === "list" ? <DecoTemplateList view={view} setView={setView} /> : <DecoTemplateEditor view={view} setView={setView} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVDecoTemplates = DecoTemplates;
|
||||
})();
|
||||
@@ -0,0 +1,341 @@
|
||||
// Decos screen — group'd deco library (list+card view) with a full-detail editor
|
||||
// for watermark / graphics / break-content / filler override rules per deco.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Checkbox } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 1, name: "Default" },
|
||||
{ id: 2, name: "Weeknights" },
|
||||
{ id: 3, name: "Retro Block" },
|
||||
];
|
||||
|
||||
const DECOS = [
|
||||
{ id: 101, groupId: 1, name: "Standard Bug" },
|
||||
{ id: 102, groupId: 1, name: "No Overlay" },
|
||||
{ id: 201, groupId: 2, name: "News Ticker" },
|
||||
{ id: 202, groupId: 2, name: "Prime Time Bumper" },
|
||||
{ id: 301, groupId: 3, name: "Retro Bumper" },
|
||||
{ id: 302, groupId: 3, name: "VHS Overlay" },
|
||||
{ id: 303, groupId: 3, name: "Static Break Loop" },
|
||||
];
|
||||
|
||||
const WATERMARK_MODE_OPTIONS = [
|
||||
{ label: "Inherit", value: "Inherit" },
|
||||
{ label: "Disable", value: "Disable" },
|
||||
{ label: "Replace", value: "Override" },
|
||||
{ label: "Merge", value: "Merge" },
|
||||
];
|
||||
const BASIC_MODE_OPTIONS = [
|
||||
{ label: "Inherit", value: "Inherit" },
|
||||
{ label: "Disable", value: "Disable" },
|
||||
{ label: "Override", value: "Override" },
|
||||
];
|
||||
const FILLER_TYPE_OPTIONS = [
|
||||
{ label: "Collection", value: "Collection" },
|
||||
{ label: "Television Show", value: "TelevisionShow" },
|
||||
{ label: "Television Season", value: "TelevisionSeason" },
|
||||
{ label: "Artist", value: "Artist" },
|
||||
{ label: "Multi Collection", value: "MultiCollection" },
|
||||
{ label: "Smart Collection", value: "SmartCollection" },
|
||||
];
|
||||
const PLACEMENT_LABEL = {
|
||||
BlockStart: "Block Start",
|
||||
BlockFinish: "Block Finish",
|
||||
BetweenBlockItems: "Between Block Items",
|
||||
ChapterMarkers: "At Chapter Markers",
|
||||
};
|
||||
|
||||
const WATERMARKS = [
|
||||
{ id: 1, name: "Bug — bottom right" },
|
||||
{ id: 2, name: "Bug — bottom left, translucent" },
|
||||
{ id: 3, name: "Station ID corner mark" },
|
||||
];
|
||||
const GRAPHICS = [
|
||||
{ id: 1, name: "Now/Next lower third" },
|
||||
{ id: 2, name: "Weather crawl" },
|
||||
{ id: 3, name: "Retro scanlines overlay" },
|
||||
];
|
||||
const BREAK_ITEMS = [
|
||||
{ key: "b1", placement: "BlockStart", collectionType: "Playlist", selectionName: "Bumper Playlist — Retro" },
|
||||
{ key: "b2", placement: "BetweenBlockItems", collectionType: "Collection", selectionName: "Commercial Bumps 90s" },
|
||||
{ key: "b3", placement: "ChapterMarkers", collectionType: "Playlist", selectionName: "Station ID Loop" },
|
||||
];
|
||||
|
||||
const EDIT_DRAFT = {
|
||||
groupName: "Retro Block",
|
||||
name: "Retro Bumper",
|
||||
watermarkMode: "Override",
|
||||
watermarkIds: [1, 3],
|
||||
useWatermarkDuringFiller: true,
|
||||
graphicsMode: "Merge",
|
||||
graphicsIds: [3],
|
||||
useGraphicsDuringFiller: false,
|
||||
breakContentMode: "Override",
|
||||
defaultFillerMode: "Override",
|
||||
defaultFillerType: "Collection",
|
||||
defaultFillerName: "Retro Filler Loop",
|
||||
defaultFillerTrimToFit: true,
|
||||
deadAirMode: "Inherit",
|
||||
};
|
||||
|
||||
function Row({ label, children, control = 360, first = false }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
padding: "12px var(--pad-cell-x)",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 6 }}>
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlushRow({ children, first = false }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0 var(--pad-cell-x)",
|
||||
height: 44,
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyNote({ children }) {
|
||||
return (
|
||||
<div style={{ padding: "18px var(--pad-cell-x)", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelect({ options, selected, disabled }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{options.map((o) => (
|
||||
<Checkbox key={o.id} checked={selected.includes(o.id)} disabled={disabled} label={o.name} onChange={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Decos() {
|
||||
const [screen, setScreen] = React.useState("list"); // "list" | "edit"
|
||||
const [selectedBreak, setSelectedBreak] = React.useState(BREAK_ITEMS[0].key);
|
||||
const [draft, setDraft] = React.useState(EDIT_DRAFT);
|
||||
|
||||
const patch = (p) => setDraft((d) => ({ ...d, ...p }));
|
||||
const watermarkActive = draft.watermarkMode === "Override" || draft.watermarkMode === "Merge";
|
||||
const graphicsActive = draft.graphicsMode === "Override" || draft.graphicsMode === "Merge";
|
||||
const selectedBreakItem = BREAK_ITEMS.find((b) => b.key === selectedBreak) || null;
|
||||
|
||||
const openEditor = () => setScreen("edit");
|
||||
const backToList = () => setScreen("list");
|
||||
|
||||
if (screen === "edit") {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58, position: "sticky", top: 0, background: "var(--surface-app)", zIndex: 3 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={backToList}>All decos</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{draft.groupName} / Edit deco</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save deco</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16, maxWidth: 900 }}>
|
||||
<Card padded={false} title="Deco">
|
||||
<Row label="Deco group" first>
|
||||
<Input size="sm" disabled value={draft.groupName} />
|
||||
</Row>
|
||||
<Row label="Deco name">
|
||||
<Input size="sm" value={draft.name} onChange={() => {}} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Watermark">
|
||||
<Row label="Watermark mode" first>
|
||||
<Select value={draft.watermarkMode} options={WATERMARK_MODE_OPTIONS} onChange={(e) => patch({ watermarkMode: e.target.value })} />
|
||||
</Row>
|
||||
<Row label="Watermarks">
|
||||
<MultiSelect options={WATERMARKS} selected={draft.watermarkIds} disabled={!watermarkActive} />
|
||||
</Row>
|
||||
<Row label="Use watermark during filler">
|
||||
<Checkbox checked={draft.useWatermarkDuringFiller} disabled={draft.watermarkMode !== "Override"} onChange={(v) => patch({ useWatermarkDuringFiller: v })} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Graphics elements">
|
||||
<Row label="Graphics elements mode" first>
|
||||
<Select value={draft.graphicsMode} options={WATERMARK_MODE_OPTIONS} onChange={(e) => patch({ graphicsMode: e.target.value })} />
|
||||
</Row>
|
||||
<Row label="Graphics elements">
|
||||
<MultiSelect options={GRAPHICS} selected={draft.graphicsIds} disabled={!graphicsActive} />
|
||||
</Row>
|
||||
<Row label="Use graphics elements during filler">
|
||||
<Checkbox checked={draft.useGraphicsDuringFiller} disabled={draft.graphicsMode !== "Override"} onChange={(v) => patch({ useGraphicsDuringFiller: v })} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padded={false}
|
||||
title="Break content"
|
||||
actions={draft.breakContentMode === "Override" ? <Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add break content</Button> : undefined}
|
||||
>
|
||||
<Row label="Break content mode" first>
|
||||
<Select value={draft.breakContentMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ breakContentMode: e.target.value })} />
|
||||
</Row>
|
||||
{draft.breakContentMode === "Override" && (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Placement", "Type", "Name", ""].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 32, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{BREAK_ITEMS.map((item) => {
|
||||
const active = item.key === selectedBreak;
|
||||
return (
|
||||
<tr key={item.key} onClick={() => setSelectedBreak(item.key)} style={{ cursor: "pointer", background: active ? "var(--ctv-accent-soft)" : "transparent", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{PLACEMENT_LABEL[item.placement]}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{item.collectionType}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{item.selectionName}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{draft.breakContentMode === "Override" && selectedBreakItem && (
|
||||
<Card padded={false} title="Break content item">
|
||||
<Row label="Placement" first>
|
||||
<Select value={selectedBreakItem.placement} options={Object.entries(PLACEMENT_LABEL).map(([value, label]) => ({ value, label }))} onChange={() => {}} />
|
||||
</Row>
|
||||
{selectedBreakItem.collectionType === "Playlist" ? (
|
||||
<React.Fragment>
|
||||
<Row label="Playlist group">
|
||||
<Select value="retro" options={[{ value: "retro", label: "Retro Bumpers" }, { value: "news", label: "News Break" }]} onChange={() => {}} />
|
||||
</Row>
|
||||
<Row label="Playlist">
|
||||
<Select value="loop1" options={[{ value: "loop1", label: "Bumper Playlist — Retro" }, { value: "loop2", label: "Late Night Loop" }]} onChange={() => {}} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Row label="Selection">
|
||||
<Badge tone="accent">{selectedBreakItem.selectionName}</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padded={false} title="Default filler">
|
||||
<Row label="Default filler mode" first>
|
||||
<Select value={draft.defaultFillerMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ defaultFillerMode: e.target.value })} />
|
||||
</Row>
|
||||
{draft.defaultFillerMode === "Override" && (
|
||||
<React.Fragment>
|
||||
<Row label="Collection type">
|
||||
<Select value={draft.defaultFillerType} options={FILLER_TYPE_OPTIONS} onChange={(e) => patch({ defaultFillerType: e.target.value })} />
|
||||
</Row>
|
||||
<Row label={FILLER_TYPE_OPTIONS.find((o) => o.value === draft.defaultFillerType)?.label}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Badge tone="accent">{draft.defaultFillerName}</Badge>
|
||||
<Input size="sm" placeholder="Type to search…" value="" onChange={() => {}} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Trim to fit">
|
||||
<Checkbox checked={draft.defaultFillerTrimToFit} onChange={(v) => patch({ defaultFillerTrimToFit: v })} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Dead air fallback">
|
||||
<Row label="Dead air fallback mode" first>
|
||||
<Select value={draft.deadAirMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ deadAirMode: e.target.value })} />
|
||||
</Row>
|
||||
{draft.deadAirMode === "Override" && (
|
||||
<Row label="Collection">
|
||||
<Badge tone="accent">(none)</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{DECOS.length} decos</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{GROUPS.map((g) => {
|
||||
const groupDecos = DECOS.filter((d) => d.groupId === g.id);
|
||||
return (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New deco</Button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groupDecos.length === 0 ? (
|
||||
<EmptyNote>No decos in this group.</EmptyNote>
|
||||
) : (
|
||||
groupDecos.map((d, i) => (
|
||||
<FlushRow key={d.id} first={i === 0}>
|
||||
<Ico n="Palette" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
style={{ flex: 1, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete deco"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</FlushRow>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVDecos = Decos;
|
||||
})();
|
||||
@@ -1,108 +0,0 @@
|
||||
// EPG / Guide grid — spec 4.4: channels down the left, time across the top,
|
||||
// programme blocks, a vertical "now" marker, current programme uses the focal
|
||||
// (live) color, filler muted, jump-to-now + time scrubber.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Select, ChannelLogo } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SLOT_W = 156; // px per 30-min slot
|
||||
const RAIL_W = 196; // channel rail
|
||||
const ROW_H = 82;
|
||||
const HEAD_H = 34;
|
||||
const NOW_SLOTS = 31 / 30; // 20:31 → just past the first slot
|
||||
|
||||
const catTone = { Filler: "var(--text-disabled)" };
|
||||
|
||||
function Programme({ p }) {
|
||||
const filler = p.filler;
|
||||
const live = p.live;
|
||||
return (
|
||||
<div title={`${p.title}${p.sub ? " — " + p.sub : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: p.offset * SLOT_W + 3,
|
||||
width: p.span * SLOT_W - 6,
|
||||
top: 5, bottom: 5,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: "8px 10px",
|
||||
overflow: "hidden",
|
||||
background: live ? "var(--ctv-live-soft)" : filler ? "var(--ctv-bg-sunken)" : "var(--ctv-surface-2)",
|
||||
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
|
||||
display: "flex", flexDirection: "column", gap: 3,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
{live && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--status-live)", flex: "0 0 auto" }} />}
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: filler ? "var(--text-disabled)" : "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.title}</span>
|
||||
</div>
|
||||
{p.sub && p.span > 1 && (
|
||||
<span style={{ font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.sub}</span>
|
||||
)}
|
||||
{p.span > 1 && (
|
||||
<span style={{ marginTop: "auto", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.cat}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Epg() {
|
||||
const [filter, setFilter] = React.useState("All channels");
|
||||
const totalW = RAIL_W + D.epgSlots.length * SLOT_W;
|
||||
const nowX = RAIL_W + NOW_SLOTS * SLOT_W;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div style={{ width: 190 }}>
|
||||
<Select size="sm" value={filter} onChange={(e) => setFilter(e.target.value)} options={["All channels", "Favorites", "News", "Kids", "Movies"]} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>20:00</span>
|
||||
<input type="range" min="0" max="100" defaultValue="12" style={{ flex: 1, accentColor: "var(--ctv-accent)", maxWidth: 320 }} />
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>02:00</span>
|
||||
</div>
|
||||
<Badge tone="accent" dot>Now 20:31</Badge>
|
||||
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
|
||||
</div>
|
||||
|
||||
{/* grid */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", background: "var(--surface-app)" }}>
|
||||
<div style={{ position: "relative", width: totalW, minWidth: "100%" }}>
|
||||
{/* time header */}
|
||||
<div style={{ display: "flex", position: "sticky", top: 0, zIndex: 3, height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 4, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
|
||||
{D.epgSlots.map((t, i) => (
|
||||
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 10px", borderRight: "1px solid var(--border-hairline)", ...mono, fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* now marker */}
|
||||
<div style={{ position: "absolute", top: HEAD_H, bottom: 0, left: nowX, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
|
||||
<span style={{ position: "absolute", top: -6, left: -4, width: 10, height: 10, borderRadius: "50%", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
|
||||
</div>
|
||||
|
||||
{/* channel rows */}
|
||||
{D.epg.map((ch, i) => (
|
||||
<div key={ch.num} style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
{/* rail */}
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 1, display: "flex", alignItems: "center", gap: 10, padding: "0 12px", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)", width: 26, flex: "0 0 auto" }}>{ch.num}</span>
|
||||
<ChannelLogo name={ch.name} size={30} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
|
||||
</div>
|
||||
{/* programme track */}
|
||||
<div style={{ position: "relative", flex: 1, background: i % 2 ? "rgba(255,255,255,0.008)" : "transparent" }}>
|
||||
{ch.programmes.map((p, j) => <Programme key={j} p={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
window.CTVEpg = Epg;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 24,
|
||||
padding: "13px 20px",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 auto", minWidth: 0, paddingTop: 5 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 460 }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBar({ children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileList({ onOpen, onAdd }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ActionBar>
|
||||
<Badge tone="neutral">{PROFILES.length} profiles</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={onAdd}>
|
||||
New profile
|
||||
</Button>
|
||||
</ActionBar>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{PROFILES.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "0 16px",
|
||||
height: "var(--row-h, 52px)",
|
||||
minHeight: 52,
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<Ico n="SlidersHorizontal" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(p)}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--ctv-accent)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
<Badge tone="neutral">{p.resolution}</Badge>
|
||||
<Badge tone="neutral">{`${p.video} / ${p.audio}`}</Badge>
|
||||
<Tooltip label={`Copy ${p.name}`}>
|
||||
<IconButton size="sm" variant="ghost" title={`Copy ${p.name}`} onClick={() => onAdd()}>
|
||||
<Ico n="Copy" s={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<IconButton size="sm" variant="ghost" title="Delete">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<React.Fragment>
|
||||
<ActionBar>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>
|
||||
All profiles
|
||||
</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{isAdd ? "New FFmpeg profile" : "Edit FFmpeg profile"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>
|
||||
{isAdd ? "Add profile" : "Save profile"}
|
||||
</Button>
|
||||
</ActionBar>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card padded={false} title="General">
|
||||
<Row first label="Name" control={360}>
|
||||
<Input size="sm" value={isAdd ? "" : profile.name} placeholder="e.g. 1080p30 Software" />
|
||||
</Row>
|
||||
<Row label="Thread count" control={200}>
|
||||
<Input size="sm" type="number" trailing="threads" value="0" />
|
||||
</Row>
|
||||
<Row label="Normalize audio" control={200}>
|
||||
<Checkbox checked={normalizeAudio} onChange={setNormalizeAudio} />
|
||||
</Row>
|
||||
<Row label="Normalize video" control={200}>
|
||||
<Checkbox checked={normalizeVideo} onChange={setNormalizeVideo} />
|
||||
</Row>
|
||||
<Row label="Preferred resolution" control={360}>
|
||||
<Select value={isAdd ? RESOLUTION_OPTIONS[0] : profile.resolution} options={RESOLUTION_OPTIONS} />
|
||||
</Row>
|
||||
{normalizeVideo && (
|
||||
<React.Fragment>
|
||||
<Row label="Scaling behavior" control={360}>
|
||||
<Select value="ScaleAndPad" options={SCALING_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Pad mode" help="Hardware padding only applies with VAAPI; otherwise software padding is used.">
|
||||
<Select disabled={hwaccel !== "Vaapi"} value="Software" options={PAD_MODE_OPTIONS} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Video">
|
||||
{normalizeVideo ? (
|
||||
<React.Fragment>
|
||||
<Row first label="Format" control={360}>
|
||||
<Select value={isAdd ? "H264" : "Hevc"} options={VIDEO_FORMAT_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Profile" control={360}>
|
||||
<Select value="high" options={VIDEO_PROFILE_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Preset" control={360}>
|
||||
<Select disabled={hwaccel === "None"} value={hwaccel === "None" ? "" : "llhq"} options={PRESET_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Allow B-frames" control={200}>
|
||||
<Checkbox checked={false} />
|
||||
</Row>
|
||||
<Row label="Bit depth" control={360}>
|
||||
<Select value="EightBit" options={BIT_DEPTH_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Bitrate" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit/s" value="2000" />
|
||||
</Row>
|
||||
<Row label="Buffer size" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit" value="4000" />
|
||||
</Row>
|
||||
<Row label="Hardware acceleration" control={360}>
|
||||
<Select value={hwaccel} options={HWACCEL_OPTIONS} onChange={(e) => setHwaccel(e.target.value)} />
|
||||
</Row>
|
||||
{hwaccel === "Vaapi" && (
|
||||
<React.Fragment>
|
||||
<Row label="VAAPI driver" control={360}>
|
||||
<Select value="Default" options={["Default", "iHD", "i965", "RadeonSI", "Nouveau"]} />
|
||||
</Row>
|
||||
<Row label="VAAPI display" control={360} help="Rendering display, e.g. drm.">
|
||||
<Input size="sm" value="drm" />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{(hwaccel === "Vaapi" || hwaccel === "Qsv") && (
|
||||
<Row label={hwaccel === "Vaapi" ? "VAAPI device" : "QSV device"} control={360} help="Render device, e.g. /dev/dri/renderD128.">
|
||||
<Input size="sm" value="/dev/dri/renderD128" />
|
||||
</Row>
|
||||
)}
|
||||
{hwaccel === "Qsv" ? (
|
||||
<Row label="QSV extra hardware frames" control={200}>
|
||||
<Input size="sm" type="number" value="64" />
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Tonemap algorithm" control={360}>
|
||||
<Select value="Linear" options={TONEMAP_OPTIONS} />
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Normalize frame rate" control={200}>
|
||||
<Checkbox checked={false} />
|
||||
</Row>
|
||||
<Row label="Normalize colors" control={200}>
|
||||
<Checkbox checked={true} />
|
||||
</Row>
|
||||
<Row label="Auto deinterlace video" control={200}>
|
||||
<Checkbox checked={true} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: 16, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Video will be copied as-is, including timestamps, which will cause issues with most clients.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Audio">
|
||||
{normalizeAudio ? (
|
||||
<React.Fragment>
|
||||
<Row first label="Format" control={360}>
|
||||
<Select value="Aac" options={AUDIO_FORMAT_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Bitrate" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit/s" value="192" />
|
||||
</Row>
|
||||
<Row label="Buffer size" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit" value="384" />
|
||||
</Row>
|
||||
<Row label="Channels" control={200}>
|
||||
<Input size="sm" type="number" value="2" />
|
||||
</Row>
|
||||
<Row label="Sample rate" control={220}>
|
||||
<Input size="sm" type="number" trailing="kHz" value="48" />
|
||||
</Row>
|
||||
<Row label="Normalize loudness" control={360}>
|
||||
<Select value={loudness} options={LOUDNESS_OPTIONS} onChange={(e) => setLoudness(e.target.value)} />
|
||||
</Row>
|
||||
{loudness === "LoudNorm" && (
|
||||
<Row label="Target loudness" control={220}>
|
||||
<Input size="sm" type="number" trailing="LUFS" value="-23" />
|
||||
</Row>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: 16, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Audio will be copied as-is, including timestamps, which will cause issues with most clients.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function FFmpegProfiles() {
|
||||
const [screen, setScreen] = React.useState({ kind: "list" });
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{screen.kind === "list" ? (
|
||||
<ProfileList onOpen={(p) => setScreen({ kind: "edit", profile: p })} onAdd={() => setScreen({ kind: "add" })} />
|
||||
) : (
|
||||
<ProfileEditor
|
||||
profile={screen.profile}
|
||||
isAdd={screen.kind === "add"}
|
||||
onBack={() => setScreen({ kind: "list" })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVFFmpegProfiles = FFmpegProfiles;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 24, padding: "14px var(--pad-card)", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 6 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-faint)", maxWidth: 460 }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setView("list")}>All filler presets</Button>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{isEdit ? "Edit filler preset" : "New filler preset"}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>{isEdit ? "Save filler preset" : "Add filler preset"}</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false} title="Filler preset">
|
||||
<Row control={360} first label="Name">
|
||||
<Input size="sm" value={active ? active.name : ""} placeholder="e.g. Ad Break — Standard" />
|
||||
</Row>
|
||||
<Row control={360} label="Kind" help={isEdit ? "Kind cannot be changed after creation." : undefined}>
|
||||
<Select disabled={isEdit} value={kind} onChange={(e) => setKind(e.target.value)} options={KIND_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Mode">
|
||||
<Select disabled={modeLocked} value={modeLocked ? "" : fmode} onChange={(e) => setFmode(e.target.value)} options={modeLocked ? [{ value: "", label: "(n/a)" }] : MODE_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Duration">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "0" : ""} trailing="h" />
|
||||
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "0" : ""} trailing="m" />
|
||||
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "15" : ""} trailing="s" />
|
||||
</div>
|
||||
</Row>
|
||||
<Row control={200} label="Count">
|
||||
<Input size="sm" type="number" disabled={fmode !== "Count" && fmode !== "Random Count"} value={fmode === "Count" || fmode === "Random Count" ? "2" : ""} />
|
||||
</Row>
|
||||
<Row control={360} label="Pad to nearest minute">
|
||||
<Select disabled={fmode !== "Pad"} value={fmode === "Pad" ? "15" : ""} options={[{ value: "", label: "(none)" }, ...PAD_OPTIONS]} />
|
||||
</Row>
|
||||
<Row control={200} label="Allow watermarks">
|
||||
<Checkbox checked={false} onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={360} label="Collection type">
|
||||
<Select value="Collection" options={COLLECTION_TYPE_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Collection">
|
||||
<Select value={active ? active.collection : ""} options={[active ? active.collection : "(none)", "Commercials 2024", "Bumpers / Short", "Promos"]} />
|
||||
</Row>
|
||||
<Row control={200} label="Use chapters as media items" help="Schedule individual chapters instead of entire files.">
|
||||
<Checkbox checked={false} disabled={kind === "Fallback"} onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={360} label="Expression" help="Mid-roll only: add filler only when this expression is true for a mid-roll point.">
|
||||
<Input size="sm" disabled={kind !== "Mid-Roll"} value={kind === "Mid-Roll" ? "chapter_index % 2 == 0" : ""} />
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{PRESETS.length} presets</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={openAdd}>New filler preset</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{PRESETS.map((p, i) => {
|
||||
const isHover = hover === p.id;
|
||||
return (
|
||||
<div key={p.id} onMouseEnter={() => 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)" }}>
|
||||
<Ico n="Film" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" onClick={() => openEdit(p)}
|
||||
style={{ flex: 1, minWidth: 0, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{p.name}
|
||||
</button>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)", minWidth: 160, textAlign: "right" }}>{p.collection}</span>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)", minWidth: 90, textAlign: "right" }}>{p.detail}</span>
|
||||
<Badge tone={p.mode === "—" ? "neutral" : "neutral"}>{p.mode}</Badge>
|
||||
<Badge tone={KIND_TONE[p.kind]}>{p.kind}</Badge>
|
||||
<div style={{ display: "flex", alignItems: "center", opacity: isHover ? 1 : 0.4, transition: "opacity var(--dur-fast)" }}>
|
||||
<Tooltip label="Delete"><IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVFillerPresets = FillerPresets;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div title={p.subTitle ? `${p.title} - ${p.subTitle}` : p.title}
|
||||
style={{
|
||||
position: "absolute", left, width, top: 5, bottom: 5,
|
||||
display: "flex", flexDirection: "column", gap: 3, minWidth: 0, overflow: "hidden",
|
||||
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: live ? "var(--ctv-live-soft)" : p.filler ? "var(--ctv-bg-sunken)" : "var(--surface-raised)",
|
||||
padding: "var(--space-4) var(--space-5)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", minWidth: 0 }}>
|
||||
{live && <StatusDot status="live" size={6} />}
|
||||
<strong style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: p.filler ? "var(--text-disabled)" : "var(--text-primary)" }}>{p.title}</strong>
|
||||
</div>
|
||||
{p.subTitle && <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)" }}>{p.subTitle}</span>}
|
||||
{p.filler ? (
|
||||
<Badge tone="neutral">Filler</Badge>
|
||||
) : p.category && (
|
||||
<small style={{ marginTop: "auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.category}</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideRow({ channel, index }) {
|
||||
return (
|
||||
<div style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 3, display: "flex", alignItems: "center", gap: "var(--space-5)", padding: "0 var(--space-6)", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
|
||||
<code style={{ ...mono, width: 30, flex: "0 0 auto", color: "var(--status-live)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)" }}>{channel.number}</code>
|
||||
<ChannelLogo name={channel.name} size={30} />
|
||||
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{channel.name}</span>
|
||||
{channel.onAir && <StatusDot status="live" size={7} />}
|
||||
</div>
|
||||
<div style={{ position: "relative", flex: 1, minWidth: 0, background: index % 2 ? "color-mix(in srgb, var(--surface-raised) 18%, transparent)" : "transparent" }}>
|
||||
{channel.programmes.length === 0 ? (
|
||||
<span style={{ position: "absolute", top: "50%", left: "var(--space-6)", transform: "translateY(-50%)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No programmes in this window</span>
|
||||
) : channel.programmes.map((p, i) => (
|
||||
<ProgrammeBlock key={i} p={p} live={channel.onAir && nowInWindow && nowMin >= p.startMin && nowMin < p.stopMin} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Guide() {
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateRows: "auto minmax(0, 1fr)", gap: "var(--space-6)", height: "100%", minHeight: 0, padding: "var(--space-6) 20px" }}>
|
||||
{/* toolbar */}
|
||||
<section style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: "var(--space-5)", padding: "var(--space-5) 0", borderBottom: "1px solid var(--border-hairline)" }} aria-label="Guide controls">
|
||||
<div style={{ width: 190, flex: "0 1 190px" }}>
|
||||
<Select disabled label="Channel group" options={["All channels"]} size="sm" value="All channels" />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, maxWidth: 420, flex: "1 1 240px", display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
|
||||
<span style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{formatTime(WINDOW_START)}</span>
|
||||
<input aria-label="Guide window position" disabled max="100" min="0" type="range" value={windowPct} readOnly style={{ minWidth: 0, flex: 1, accentColor: "var(--action-primary)" }} />
|
||||
<span style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{formatTime(WINDOW_END)}</span>
|
||||
</div>
|
||||
<Badge tone={nowInWindow ? "accent" : "neutral"} dot={nowInWindow}>Now {formatTime(NOW)}</Badge>
|
||||
<Button variant="secondary">Previous</Button>
|
||||
<Button variant="secondary">Next guide window</Button>
|
||||
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
|
||||
</section>
|
||||
|
||||
{/* grid */}
|
||||
<section style={{ minHeight: 420, overflow: "hidden", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-sm)", background: "var(--surface-app)" }} aria-label="Guide timeline">
|
||||
<div style={{ width: "100%", height: "100%", overflow: "auto" }}>
|
||||
<div style={{ position: "relative", width: totalWidth, minWidth: "100%" }}>
|
||||
<div style={{ position: "sticky", top: 0, zIndex: 4, display: "flex", height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 5, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
|
||||
{slots.map((slot, i) => (
|
||||
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 var(--space-5)", borderRight: "1px solid var(--border-hairline)", ...mono, color: "var(--text-secondary)", fontSize: "var(--text-xs)" }}>{formatTime(slot)}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nowInWindow && (
|
||||
<div aria-hidden="true" style={{ position: "absolute", top: HEAD_H, bottom: 0, left: RAIL_W + nowOffset, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
|
||||
<span style={{ position: "absolute", top: -5, left: -4, width: 10, height: 10, borderRadius: "var(--radius-pill)", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{CHANNELS.map((channel, i) => <GuideRow channel={channel} index={i} key={channel.number} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVGuide = Guide;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 50 }} onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => 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)" }}
|
||||
>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", marginBottom: 14 }}>
|
||||
Duration — {folder.name}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Input label="Seconds per image" value={value} onChange={(e) => setValue(e.target.value)} placeholder="Inherited from parent" type="number" fullWidth />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Button size="sm" variant="ghost">Clear</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" variant="primary" onClick={onClose}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({ folder, depth, expandedMap, onToggle, onEdit }) {
|
||||
const hasChildren = folder.subfolderCount > 0;
|
||||
const isExpanded = !!expandedMap[folder.id];
|
||||
const isLoading = !!folder.loading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
height: "var(--row-h, 44px)",
|
||||
paddingLeft: depth * 20 + 8,
|
||||
paddingRight: 12,
|
||||
borderTop: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<IconButton size="sm" title={hasChildren ? (isExpanded ? "Collapse" : "Expand") : "No subfolders"} disabled={!hasChildren} onClick={() => hasChildren && onToggle(folder.id)}>
|
||||
{hasChildren ? <Ico n={isExpanded ? "ChevronDown" : "ChevronRight"} s={15} /> : <Ico n="FolderTree" s={15} color="var(--text-faint)" />}
|
||||
</IconButton>
|
||||
<span
|
||||
title={folder.fullPath}
|
||||
style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 1 auto", maxWidth: 320 }}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", whiteSpace: "nowrap" }}>
|
||||
{folder.imageCount} image{folder.imageCount === 1 ? "" : "s"} · {folder.subfolderCount} subfolder{folder.subfolderCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-xs)/1 var(--font-sans)", color: folder.durationSeconds != null ? "var(--text-secondary)" : "var(--text-faint)", whiteSpace: "nowrap" }}>
|
||||
<Ico n="Clock" s={13} />
|
||||
<span style={folder.durationSeconds != null ? mono : undefined}>{folder.durationSeconds != null ? `${folder.durationSeconds}s` : "inherited"}</span>
|
||||
</span>
|
||||
<Tooltip label="Edit duration">
|
||||
<IconButton size="sm" title="Edit duration" onClick={() => onEdit(folder)}>
|
||||
<Ico n="Pencil" s={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Search this folder">
|
||||
<IconButton size="sm" title="Search this folder">
|
||||
<Ico n="Search" s={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{isExpanded && isLoading ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: depth * 20 + 28, paddingTop: 8, paddingBottom: 8, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Spinner size={14} />
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isExpanded && folder.children && folder.children.length > 0
|
||||
? folder.children.map((child) => (
|
||||
<FolderRow key={child.id} folder={child} depth={depth + 1} expandedMap={expandedMap} onToggle={onToggle} onEdit={onEdit} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8, font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="FolderTree" s={15} color="var(--text-disabled)" />
|
||||
Image library folders, indexed for slideshow scheduling
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Image" s={14} />}>Browse images</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{TREE.map((folder) => (
|
||||
<FolderRow key={folder.id} folder={folder} depth={0} expandedMap={expandedMap} onToggle={onToggle} onEdit={setEditing} />
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{editing ? <EditDurationOverlay folder={editing} onClose={() => setEditing(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVImageBrowser = ImageBrowser;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, padding: "14px 16px" }}>
|
||||
<span style={kindIconWrap(accent)}><Ico n={source.icon} s={18} /></span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{source.name}</h3>
|
||||
<StatusDot status={source.scanning ? "live" : "ok"} label={source.scanning ? "Scanning" : "Synced"} size={7} />
|
||||
</div>
|
||||
<span style={{ display: "block", marginTop: 2, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{source.address}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 2, flex: "0 0 auto" }}>
|
||||
<Tooltip label={`Scan all libraries in ${source.name}`}>
|
||||
<IconButton size="sm" title="Scan all" disabled={source.scanning}><Ico n="RefreshCw" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
{source.kind !== "Local" && (
|
||||
<Tooltip label={`Manage ${source.name}`}>
|
||||
<IconButton size="sm" title="Manage"><Ico n="Settings" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryRow({ library, editable, allowDeepScan }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 26, height: 26, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
|
||||
<Ico n={library.icon} s={14} />
|
||||
</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ display: "block", font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{library.name}</strong>
|
||||
<span style={{ display: "block", marginTop: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{library.mediaKind === "MusicVideos" ? "Music Videos" : library.mediaKind === "OtherVideos" ? "Other Videos" : library.mediaKind} · <span style={mono}>{library.itemCount.toLocaleString()}</span> items
|
||||
</span>
|
||||
<small style={{ display: "block", marginTop: 1, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
{library.lastScan ? `Last scan ${fmtTime(library.lastScan)}` : "Never scanned"}
|
||||
</small>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
|
||||
{library.scanning ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 92 }}><ProgressBar value={library.percent} tone="accent" showLabel /></div>
|
||||
<Badge tone="accent" dot>Scanning</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge tone="ok" dot>Synced</Badge>
|
||||
)}
|
||||
{allowDeepScan && (
|
||||
<Tooltip label={`Deep scan ${library.name}`}>
|
||||
<IconButton size="sm" title="Deep scan" disabled={library.scanning}><Ico n="ScanSearch" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={`Scan ${library.name}`}>
|
||||
<IconButton size="sm" title="Scan" disabled={library.scanning}><Ico n="RefreshCw" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
{editable && (
|
||||
<Tooltip label={`Edit ${library.name}`}>
|
||||
<IconButton size="sm" title="Edit"><Ico n="Settings" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={cardShell}>
|
||||
<SourceHead source={source} />
|
||||
<div>
|
||||
{source.libraries.map((lib) => (
|
||||
<LibraryRow key={lib.id} library={lib} editable={isLocal} allowDeepScan={!isLocal} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><Ico n="Clock" s={13} color="var(--text-disabled)" />{lastScanLabel}</span>
|
||||
<span><span style={mono}>{totalItems.toLocaleString()}</span> items · {source.libraries.length} librar{source.libraries.length === 1 ? "y" : "ies"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalCollections() {
|
||||
return (
|
||||
<div style={cardShell}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, padding: "14px 16px" }}>
|
||||
<span style={kindIconWrap(false)}><Ico n="Library" s={18} /></span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>External Collections</h3>
|
||||
<span style={{ display: "block", marginTop: 2, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>Sync Plex, Jellyfin, and Emby collections into ErsatzTV.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{COLLECTIONS_ROWS.map((row) => (
|
||||
<div key={row.family} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 26, height: 26, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
|
||||
<Ico n="Server" s={14} />
|
||||
</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ display: "block", font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{row.name}</strong>
|
||||
<span style={{ display: "block", marginTop: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{familyLabel(row.family)} collections</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
|
||||
{row.scanning ? <Badge tone="accent" dot>Scanning</Badge> : <Badge tone="ok" dot>Idle</Badge>}
|
||||
<Tooltip label={`Deep scan ${row.name} collections`}>
|
||||
<IconButton size="sm" title="Deep scan" disabled={row.scanning}><Ico n="ScanSearch" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip label={`Scan ${row.name} collections`}>
|
||||
<IconButton size="sm" title="Scan" disabled={row.scanning}><Ico n="RefreshCw" s={15} /></IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSourceMenu() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />} onClick={() => setOpen((v) => !v)}>Add Source</Button>
|
||||
{open && (
|
||||
<div style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, minWidth: 190, padding: 6, borderRadius: "var(--radius-md)", border: "1px solid var(--border-hairline)", background: "var(--surface-raised)", boxShadow: "var(--shadow-pop)", zIndex: 10 }}>
|
||||
{[{ icon: "HardDrive", label: "Local library" }, { icon: "Server", label: "Plex" }, { icon: "Server", label: "Jellyfin" }, { icon: "Server", label: "Emby" }].map((it) => (
|
||||
<button key={it.label} type="button" style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", padding: "8px 10px", border: "none", background: "transparent", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--ctv-surface-2)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}>
|
||||
<Ico n={it.icon} s={14} color="var(--text-secondary)" />{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, padding: "16px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div>
|
||||
<span style={{ display: "block", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--ctv-accent)" }}>Sources</span>
|
||||
<h2 style={{ margin: "4px 0 0", font: "var(--weight-semibold) var(--text-xl)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Media Libraries</h2>
|
||||
<p style={{ margin: "4px 0 0", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>Monitor connected media sources and trigger per-library scans.</p>
|
||||
</div>
|
||||
<AddSourceMenu />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 18, padding: "10px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span><span style={mono}>{SOURCES.length}</span> sources</span>
|
||||
<span><span style={mono}>{totalItems.toLocaleString()}</span> items</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<StatusDot status={activeScans > 0 ? "live" : "ok"} size={7} pulse={activeScans > 0} />
|
||||
<span style={mono}>{activeScans}</span> active scan{activeScans === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(360px, 1fr))", gap: 16 }}>
|
||||
{SOURCES.map((source) => (
|
||||
<SourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<ExternalCollections />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVLibraries = Libraries;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 24,
|
||||
padding: "16px 0",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 auto", minWidth: 0, paddingTop: 6 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: "0 0 300px" }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 96px" }}>
|
||||
<div style={{ maxWidth: 780, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<Card title="Edit Movies">
|
||||
<Row first help="Library display name." label="Name">
|
||||
<Input error={nameValid ? null : "Name is required"} onChange={(e) => setName(e.target.value)} size="sm" value={name} />
|
||||
</Row>
|
||||
<Row help="Media kind is immutable once a library is created." label="Media kind">
|
||||
<Select disabled onChange={() => {}} options={MEDIA_KIND_OPTIONS} size="sm" value="Movies" />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card title="Paths" subtitle={`${paths.length} path${paths.length === 1 ? "" : "s"} · ${totalItems} media items`}>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{paths.length === 0 && (
|
||||
<div style={{ padding: "20px 0", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>No paths added yet.</div>
|
||||
)}
|
||||
{paths.map((p, i) => (
|
||||
<div
|
||||
key={p.id + p.path}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
height: 46,
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<Ico n="Folder" s={14} color="var(--text-disabled)" />
|
||||
<span
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
...mono,
|
||||
font: "var(--text-sm)/1 var(--font-mono)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{p.path}
|
||||
</span>
|
||||
{p.count > 0 && (
|
||||
<Badge tone="neutral">
|
||||
<span style={mono}>{p.count}</span> items
|
||||
</Badge>
|
||||
)}
|
||||
<IconButton
|
||||
disabled={p.id === 0}
|
||||
onClick={() => {
|
||||
setMoveTarget(p);
|
||||
setMoveDest("");
|
||||
}}
|
||||
size="sm"
|
||||
title={p.id === 0 ? `Save first to move ${p.path}` : `Move ${p.path} to another library`}
|
||||
>
|
||||
<Ico n="ArrowRightLeft" s={14} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => removePath(p)} size="sm" title={`Remove ${p.path}`}>
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 10, marginTop: 16, paddingTop: 16, borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input onChange={(e) => setNewPath(e.target.value)} placeholder="/media/movies" size="sm" value={newPath} />
|
||||
</div>
|
||||
<Button onClick={addPath} size="sm" startIcon={<Ico n="Plus" s={14} />}>
|
||||
Add Path
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Delete library" style={{ borderColor: "var(--status-error)" }}>
|
||||
<p style={{ margin: "0 0 14px", font: "var(--text-sm)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={mono}>{totalItems}</span> media items will be removed from the library index. This does not delete files on disk.
|
||||
</p>
|
||||
<Button onClick={() => setDeleteOpen(true)} startIcon={<Ico n="Trash2" s={14} />} variant="danger">
|
||||
Delete library
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dirty && (
|
||||
<div style={{ position: "sticky", bottom: 0, left: 0, right: 0, display: "flex", justifyContent: "center", padding: "0 24px 20px", pointerEvents: "none" }}>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "10px 14px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
background: "var(--surface-raised)",
|
||||
boxShadow: "var(--shadow-pop)",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
<Ico n="CircleAlert" s={14} color="var(--status-warn)" />
|
||||
Unsaved changes
|
||||
</span>
|
||||
{!nameValid && <Badge tone="error">Name is required</Badge>}
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<Button onClick={() => setName("Movies")} size="sm" startIcon={<Ico n="Undo2" s={14} />} variant="ghost">
|
||||
Discard
|
||||
</Button>
|
||||
<Button disabled={!nameValid} size="sm" startIcon={<Ico n="Save" s={14} />} variant="primary">
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moveTarget && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 420,
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
background: "var(--surface-raised)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "14px 18px", borderBottom: "1px solid var(--border-hairline)", font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
Move {moveTarget.path}
|
||||
</div>
|
||||
<div style={{ padding: 18, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Select
|
||||
label="Target library"
|
||||
onChange={(e) => setMoveDest(e.target.value)}
|
||||
options={[
|
||||
{ label: "(choose a library)", value: "" },
|
||||
...OTHER_LIBRARIES.map((l) => ({ label: l.name, value: String(l.id) })),
|
||||
{ label: "(New Library)", value: "__new__" },
|
||||
]}
|
||||
value={moveDest}
|
||||
/>
|
||||
{moveDest === "__new__" && <Input placeholder="New library name" onChange={() => {}} value="" />}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, padding: "14px 18px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<Button onClick={() => setMoveTarget(null)} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!moveDest} onClick={() => setMoveTarget(null)} startIcon={<Ico n="Check" s={14} />}>
|
||||
Move
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 420,
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
background: "var(--surface-raised)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "14px 18px", borderBottom: "1px solid var(--border-hairline)", font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
Delete library
|
||||
</div>
|
||||
<div style={{ padding: 18, font: "var(--text-sm)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
This library contains <span style={mono}>{totalItems}</span> media item(s). Delete it anyway?
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, padding: "14px 18px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<Button onClick={() => setDeleteOpen(false)} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setDeleteOpen(false)} startIcon={<Ico n="Trash2" s={14} />} variant="danger">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVLocalLibraryEdit = LocalLibraryEdit;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", padding: "20px", background: "var(--ctv-bg)" }}>
|
||||
<Card
|
||||
title="Welcome back"
|
||||
subtitle="Sign in to ChicoryTV"
|
||||
padded
|
||||
style={{ maxWidth: 420, width: "100%" }}
|
||||
>
|
||||
{config.localLoginEnabled && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<Input
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
leadingIcon={<Ico n="User" s={14} />}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
leadingIcon={<Ico n="KeyRound" s={14} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: "var(--ctv-error-soft)",
|
||||
color: "var(--status-error)",
|
||||
font: "var(--text-sm)/1.3 var(--font-sans)"
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
disabled={!canSubmit}
|
||||
loading={submitting}
|
||||
startIcon={<Ico n="LogIn" s={14} />}
|
||||
type="submit"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{config.oidcEnabled && (
|
||||
<div style={{ marginTop: config.localLoginEnabled ? 12 : 0 }}>
|
||||
<Button
|
||||
variant={config.localLoginEnabled ? "secondary" : "primary"}
|
||||
fullWidth
|
||||
startIcon={<Ico n="ShieldCheck" s={14} />}
|
||||
>
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVLogin = Login;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
flex: "0 0 auto",
|
||||
minHeight: 58
|
||||
}}>
|
||||
<Input
|
||||
leadingIcon={<Ico n="Search" s={14} />}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Filter by level or message…"
|
||||
value={filterInput}
|
||||
size="md"
|
||||
/>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Select
|
||||
onChange={handlePageSizeChange}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
value={String(pageSize)}
|
||||
size="sm"
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
startIcon={<Ico n="RefreshCw" s={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* main content area */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{isLoading ? (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: 48,
|
||||
color: "var(--text-secondary)"
|
||||
}}>
|
||||
<Spinner size={18} tone="accent" />
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>Loading logs…</span>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={{
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
color: "var(--text-secondary)",
|
||||
font: "var(--text-sm)/1 var(--font-sans)"
|
||||
}}>
|
||||
{filter ? 'No log entries match this filter.' : 'No log entries found.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, width: 220 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('timestamp')}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
font: "inherit",
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
Timestamp
|
||||
{sortField === 'timestamp' && (
|
||||
<Ico n={sortDirection === 'asc' ? 'ChevronUp' : 'ChevronDown'} s={13} />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th style={{ ...th, width: 130 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort('level')}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
font: "inherit",
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
Level
|
||||
{sortField === 'level' && (
|
||||
<Ico n={sortDirection === 'asc' ? 'ChevronUp' : 'ChevronDown'} s={13} />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th style={th}>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry, idx) => (
|
||||
<tr key={idx} style={{ borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<td style={{ ...td, ...mono, width: 220, whiteSpace: "nowrap" }}>
|
||||
{entry.timestamp}
|
||||
</td>
|
||||
<td style={{ ...td, width: 130 }}>
|
||||
<Badge tone={LEVEL_TONE[entry.level] || 'neutral'}>
|
||||
{entry.level}
|
||||
</Badge>
|
||||
</td>
|
||||
<td style={{ ...td, ...mono }}>
|
||||
{entry.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* footer */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
borderTop: "1px solid var(--border-hairline)",
|
||||
font: "var(--text-xs)/1 var(--font-sans)",
|
||||
color: "var(--text-secondary)",
|
||||
flex: "0 0 auto"
|
||||
}}>
|
||||
<span>
|
||||
<span style={mono}>{totalCount}</span> entr{totalCount === 1 ? 'y' : 'ies'}
|
||||
{filter && ` matching "${filter}"`}
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Previous page"
|
||||
disabled={pageNum === 0}
|
||||
onClick={() => setPageNum(Math.max(0, pageNum - 1))}
|
||||
>
|
||||
<Ico n="ChevronLeft" s={15} />
|
||||
</IconButton>
|
||||
<span style={{ ...mono, fontSize: "var(--text-xs)", padding: "0 6px" }}>
|
||||
{pageNum + 1} / {totalPages}
|
||||
</span>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Next page"
|
||||
disabled={pageNum + 1 >= totalPages}
|
||||
onClick={() => setPageNum(pageNum + 1)}
|
||||
>
|
||||
<Ico n="ChevronRight" s={15} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* callout section */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
margin: "12px 20px 0",
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "var(--ctv-accent-soft)",
|
||||
border: "1px solid var(--ctv-accent-ring)",
|
||||
font: "var(--text-xs)/1.5 var(--font-sans)",
|
||||
color: "var(--text-primary)"
|
||||
}}>
|
||||
<Ico n="Info" s={14} style={{ flex: "0 0 auto", marginTop: 1, color: "var(--ctv-accent)" }} />
|
||||
<span>
|
||||
Logs are read from the current server log file. Older, rotated log files aren't available here — check the config folder's <code style={{ ...mono, fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>logs</code> directory for history.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVLogs = Logs;
|
||||
})();
|
||||
@@ -0,0 +1,250 @@
|
||||
// Media (Browse) screen — a per-kind library browser: kind switcher + search toolbar, an
|
||||
// opt-in multi-select mode with a bulk action bar, a poster grid with hover/selection
|
||||
// overlays, and a footer with count + pagination. Static mockup of the "movies" kind.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Card, Input, Select, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
// Matches the live IA (KINDS in MediaBrowseScreen.tsx).
|
||||
const KINDS = [
|
||||
{ slug: "movies", label: "Movies", icon: "Film" },
|
||||
{ slug: "shows", label: "TV Shows", icon: "Tv" },
|
||||
{ slug: "seasons", label: "Seasons", icon: "CalendarDays" },
|
||||
{ slug: "episodes", label: "Episodes", icon: "PlaySquare" },
|
||||
{ slug: "artists", label: "Artists", icon: "Mic2" },
|
||||
{ slug: "music-videos", label: "Music Videos", icon: "Music" },
|
||||
{ slug: "songs", label: "Songs", icon: "Music2" },
|
||||
{ slug: "other-videos", label: "Other Videos", icon: "Video" },
|
||||
{ slug: "images", label: "Images", icon: "Image" },
|
||||
{ slug: "remote-streams", label: "Remote Streams", icon: "Radio" },
|
||||
];
|
||||
|
||||
function hueOf(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
const MOVIES = [
|
||||
["Blade Runner 2049", "2017 · 2h 44m"], ["Mad Max: Fury Road", "2015 · 2h 0m"],
|
||||
["The Grand Budapest Hotel", "2014 · 1h 39m"], ["Arrival", "2016 · 1h 56m"],
|
||||
["Parasite", "2019 · 2h 12m"], ["Dune", "2021 · 2h 35m"],
|
||||
["Everything Everywhere All at Once", "2022 · 2h 19m"], ["The Matrix", "1999 · 2h 16m"],
|
||||
["No Country for Old Men", "2007 · 2h 2m"], ["Whiplash", "2014 · 1h 47m"],
|
||||
["La La Land", "2016 · 2h 8m"], ["Inception", "2010 · 2h 28m"],
|
||||
["Interstellar", "2014 · 2h 49m"], ["Her", "2013 · 2h 6m"],
|
||||
["The Social Network", "2010 · 2h 0m"], ["Moonlight", "2016 · 1h 51m"],
|
||||
["Get Out", "2017 · 1h 44m"], ["Knives Out", "2019 · 2h 10m"],
|
||||
["The Lighthouse", "2019 · 1h 49m"], ["Nightcrawler", "2014 · 1h 57m"],
|
||||
["Drive", "2011 · 1h 40m"], ["Sicario", "2015 · 2h 1m"],
|
||||
["Prisoners", "2013 · 2h 33m"], ["The Batman", "2022 · 2h 56m"],
|
||||
];
|
||||
const SHOWS = [
|
||||
["Severance", "2 seasons"], ["The Bear", "3 seasons"], ["Fargo", "5 seasons"],
|
||||
["Succession", "4 seasons"], ["Chernobyl", "1 season"], ["Better Call Saul", "6 seasons"],
|
||||
["The Expanse", "6 seasons"], ["Slow Horses", "4 seasons"], ["Andor", "2 seasons"],
|
||||
["Dark", "3 seasons"], ["Mindhunter", "2 seasons"], ["Fleabag", "2 seasons"],
|
||||
];
|
||||
const SEASONS = [
|
||||
["Fargo — Season 1", "10 episodes"], ["Fargo — Season 2", "10 episodes"],
|
||||
["The Bear — Season 1", "8 episodes"], ["The Bear — Season 2", "10 episodes"],
|
||||
["Severance — Season 1", "9 episodes"], ["Dark — Season 1", "10 episodes"],
|
||||
["Succession — Season 4", "10 episodes"], ["Andor — Season 1", "12 episodes"],
|
||||
];
|
||||
const EPISODES = [
|
||||
["The Cat Concerto", "Tom & Jerry · S01E01"], ["Ozymandias", "Breaking Bad · S05E14"],
|
||||
["Chapter 7 — The Sicilian", "The Wire · S03E07"], ["Pilot", "Severance · S01E01"],
|
||||
["International Assassin", "The Leftovers · S02E08"], ["The Rains of Castamere", "GoT · S03E09"],
|
||||
["Long Day's Journey Into Night", "Fargo · S02E10"], ["The Constant", "Lost · S04E05"],
|
||||
];
|
||||
const ARTISTS = [
|
||||
["Boards of Canada", "4 albums"], ["Radiohead", "9 albums"], ["Aphex Twin", "6 albums"],
|
||||
["Portishead", "3 albums"], ["Tame Impala", "5 albums"], ["Bonobo", "7 albums"],
|
||||
];
|
||||
const MUSICVIDEOS = [
|
||||
["Paranoid Android", "Radiohead"], ["Windowlicker", "Aphex Twin"], ["Karma Police", "Radiohead"],
|
||||
["Glory Box", "Portishead"], ["The Less I Know the Better", "Tame Impala"], ["Kerala", "Bonobo"],
|
||||
];
|
||||
const SONGS = [
|
||||
["Roygbiv", "Boards of Canada · Geogaddi"], ["Idioteque", "Radiohead · Kid A"],
|
||||
["Windowlicker", "Aphex Twin · Windowlicker EP"], ["Glory Box", "Portishead · Dummy"],
|
||||
["Feels Like We Only Go Backwards", "Tame Impala · Lonerism"], ["Kerala", "Bonobo · Migration"],
|
||||
];
|
||||
const OTHER = [
|
||||
["Concert — Live at Brixton", "/media/other/concerts"], ["Behind the Scenes — Dune", "/media/other/bts"],
|
||||
["Home Movie 2019", "/media/other/family"], ["Standup Special", "/media/other/comedy"],
|
||||
];
|
||||
const IMAGES = [
|
||||
["Sunset Over the Bay", "/media/images/nature"], ["City Skyline", "/media/images/urban"],
|
||||
["Studio Portrait 04", "/media/images/portraits"], ["Mountain Range", "/media/images/nature"],
|
||||
["Neon Alley", "/media/images/urban"], ["Macro — Frost", "/media/images/nature"],
|
||||
["Studio Portrait 11", "/media/images/portraits"], ["Harbor at Dusk", "/media/images/urban"],
|
||||
];
|
||||
const REMOTE = [
|
||||
["BBC World News", "stream.bbc.example.net"], ["Classic Cartoons 24/7", "iptv.retrotoons.example"],
|
||||
["Lofi Radio Visualizer", "cdn.lofi.example.stream"], ["Weather Channel Loop", "wx.example.tv"],
|
||||
];
|
||||
|
||||
const KIND_DATA = {
|
||||
movies: MOVIES, shows: SHOWS, seasons: SEASONS, episodes: EPISODES, artists: ARTISTS,
|
||||
"music-videos": MUSICVIDEOS, songs: SONGS, "other-videos": OTHER, images: IMAGES,
|
||||
"remote-streams": REMOTE,
|
||||
};
|
||||
const KIND_TOTAL = {
|
||||
movies: 812, shows: 96, seasons: 341, episodes: 6284, artists: 58,
|
||||
"music-videos": 112, songs: 3940, "other-videos": 27, images: 214, "remote-streams": 11,
|
||||
};
|
||||
|
||||
function itemsFor(slug) {
|
||||
const rows = KIND_DATA[slug] || [];
|
||||
return rows.map(([title, subtitle], i) => ({ id: slug + "-" + i, title, subtitle, hue: hueOf(title) }));
|
||||
}
|
||||
|
||||
function PosterCard({ item, kindIcon, selectMode, selected, hovered, onHover, onLeave, onClick }) {
|
||||
const gradient = "linear-gradient(160deg, hsl(" + item.hue + " 24% 17%), hsl(" + item.hue + " 26% 12%))";
|
||||
const showOverlay = hovered || selected;
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={onHover} onMouseLeave={onLeave} onClick={onClick}
|
||||
style={{ borderRadius: "var(--radius-sm)", border: selected ? "1px solid var(--ctv-accent)" : "1px solid var(--border-hairline)", boxShadow: selected ? "0 0 0 1px var(--ctv-accent)" : "none", background: "var(--surface-card)", overflow: "hidden", cursor: "pointer", transition: "border-color var(--dur-fast), box-shadow var(--dur-fast)" }}>
|
||||
<div style={{ position: "relative", height: 150, background: gradient }}>
|
||||
<span style={{ position: "absolute", top: 7, left: 7, display: "inline-flex", color: "hsl(" + item.hue + " 30% 68%)" }}>
|
||||
<Ico n={kindIcon} s={15} />
|
||||
</span>
|
||||
{selectMode && showOverlay && !selected && (
|
||||
<span style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", border: "1.5px solid rgba(255,255,255,0.6)", background: "rgba(0,0,0,0.25)" }} />
|
||||
)}
|
||||
{selected && (
|
||||
<span style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--ctv-accent)", color: "var(--text-on-accent)", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)" }}>
|
||||
<Ico n="Check" s={13} />
|
||||
</span>
|
||||
)}
|
||||
{!selectMode && hovered && (
|
||||
<span style={{ position: "absolute", top: 6, right: 6 }}>
|
||||
<Tooltip label="Add to…">
|
||||
<IconButton size="sm" variant="solid" title="Add to…"><Ico n="Plus" s={14} /></IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "8px 10px 10px" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Media() {
|
||||
const [kindSlug, setKindSlug] = React.useState("movies");
|
||||
const [queryInput, setQueryInput] = React.useState("");
|
||||
const [selectMode, setSelectMode] = React.useState(false);
|
||||
const [selected, setSelected] = React.useState({});
|
||||
const [hoverId, setHoverId] = React.useState(null);
|
||||
|
||||
const kind = KINDS.find((k) => k.slug === kindSlug) || KINDS[0];
|
||||
const allItems = itemsFor(kindSlug);
|
||||
const q = queryInput.trim().toLowerCase();
|
||||
const items = q ? allItems.filter((it) => it.title.toLowerCase().includes(q)) : allItems;
|
||||
|
||||
const selCount = Object.values(selected).filter(Boolean).length;
|
||||
|
||||
const changeKind = (slug) => { setKindSlug(slug); setQueryInput(""); setSelected({}); };
|
||||
const toggleSelectMode = () => { setSelectMode((on) => !on); setSelected({}); };
|
||||
const toggleSelect = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }));
|
||||
const selectAllOnPage = () => { const n = { ...selected }; items.forEach((it) => (n[it.id] = true)); setSelected(n); };
|
||||
const clearSelection = () => setSelected({});
|
||||
|
||||
const totalCount = KIND_TOTAL[kindSlug] || items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / 100));
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Select
|
||||
value={kindSlug}
|
||||
onChange={(e) => changeKind(e.target.value)}
|
||||
options={KINDS.map((k) => ({ value: k.slug, label: k.label }))}
|
||||
fullWidth={false}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
placeholder={"Search " + kind.label.toLowerCase() + "…"}
|
||||
leadingIcon={<Ico n="Search" s={14} />}
|
||||
/>
|
||||
<span style={{ flex: 1 }} />
|
||||
{kindSlug === "images" && (
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="FolderTree" s={14} />}>Folder Browser</Button>
|
||||
)}
|
||||
<Button size="sm" variant={selectMode ? "primary" : "secondary"} startIcon={<Ico n="ListChecks" s={14} />} onClick={toggleSelectMode}>
|
||||
{selectMode ? "Done" : "Select"}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={14} />}>Refresh</Button>
|
||||
</div>
|
||||
|
||||
{/* bulk action bar */}
|
||||
{selCount > 0 && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", background: "var(--ctv-accent-soft)" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}><span style={mono}>{selCount}</span> selected</span>
|
||||
<Button size="sm" variant="secondary" onClick={selectAllOnPage}>Select all on page</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="FolderPlus" s={14} />}>Add to collection</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ListVideo" s={14} />}>Add to playlist</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearSelection}>Clear selection</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{items.length === 0 ? (
|
||||
<Card padded>
|
||||
<div style={{ padding: "22px 8px", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{q ? "No " + kind.label.toLowerCase() + " match this search." : "No " + kind.label.toLowerCase() + " found."}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 12 }}>
|
||||
{items.map((it) => (
|
||||
<PosterCard
|
||||
key={it.id}
|
||||
item={it}
|
||||
kindIcon={kind.icon}
|
||||
selectMode={selectMode}
|
||||
selected={!!selected[it.id]}
|
||||
hovered={hoverId === it.id}
|
||||
onHover={() => setHoverId(it.id)}
|
||||
onLeave={() => setHoverId((h) => (h === it.id ? null : h))}
|
||||
onClick={() => (selectMode ? toggleSelect(it.id) : null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span><span style={mono}>{totalCount}</span> {kind.label.toLowerCase()}</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<Tooltip label="Previous page"><IconButton size="sm" title="Previous page" disabled><Ico n="ChevronLeft" s={15} /></IconButton></Tooltip>
|
||||
<span style={{ ...mono, fontSize: "var(--text-xs)", padding: "0 6px", color: "var(--text-secondary)" }}>Page 1 of {totalPages}</span>
|
||||
<Tooltip label="Next page"><IconButton size="sm" title="Next page"><Ico n="ChevronRight" s={15} /></IconButton></Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)", color: "var(--text-disabled)" }}>
|
||||
<Ico n="Info" s={14} />
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Browsing indexed library items. Use the search box for a quick per-kind filter, or the global search for results across every media kind.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVMedia = Media;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "baseline", flexWrap: "wrap" }}>
|
||||
<span style={{ minWidth: 130, font: "var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)" }}>{label}</span>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", flex: 1 }}>
|
||||
{values.map((v) => <Tag key={v}>{v}</Tag>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PosterThumb({ size, radius, hue }) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: radius, flexShrink: 0, position: "relative", overflow: "hidden", border: "1px solid var(--border-hairline)", background: `linear-gradient(160deg, hsl(${hue} 24% 22%), hsl(${hue} 26% 14%))` }}>
|
||||
<div style={{ position: "absolute", inset: 0, backgroundImage: `repeating-linear-gradient(135deg, hsl(${hue} 22% 10% / .5) 0 1px, transparent 1px 9px)` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActorsRow({ actors }) {
|
||||
if (!actors || actors.length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Cast</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))", gap: 12 }}>
|
||||
{actors.map((a) => (
|
||||
<div key={a.id} style={{ display: "flex", flexDirection: "column", gap: 4, textAlign: "center" }}>
|
||||
<div style={{ width: "100%", aspectRatio: "1 / 1", borderRadius: "50%", background: "var(--ctv-surface-3)", border: "1px solid var(--border-hairline)" }} />
|
||||
<div style={{ font: "var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.name}</div>
|
||||
{a.role ? <div style={{ font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.role}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildGrid({ heading, kind, count, page, pages }) {
|
||||
const items = CHILD_ITEMS[kind] || [];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{heading}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))", gap: 12 }}>
|
||||
{items.map((item, idx) => {
|
||||
const hue = (idx * 47 + kind.length * 13) % 360;
|
||||
return (
|
||||
<div key={item.id} style={{ display: "flex", flexDirection: "column", gap: 6, cursor: "pointer" }}>
|
||||
<div style={{ position: "relative", borderRadius: "var(--radius-sm)", overflow: "hidden", border: "1px solid var(--border-hairline)", background: `linear-gradient(160deg, hsl(${hue} 24% 20%), hsl(${hue} 26% 12%))`, height: 150 }}>
|
||||
<div style={{ position: "absolute", inset: 0, backgroundImage: `repeating-linear-gradient(135deg, hsl(${hue} 22% 10% / .5) 0 1px, transparent 1px 9px)` }} />
|
||||
<div style={{ position: "absolute", top: 6, right: 6, display: "flex", alignItems: "center", justifyContent: "center", width: 24, height: 24, borderRadius: "var(--radius-sm)", background: "rgba(0,0,0,0.45)", backdropFilter: "blur(2px)" }}>
|
||||
<Ico n={kind === "video" ? "Music" : "PlayCircle"} s={13} color="rgba(255,255,255,0.85)" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
|
||||
<div style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{item.sub}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={mono}>{count}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<IconButton size="sm" title="Previous page" disabled={page <= 1}><Ico n="ChevronLeft" s={15} /></IconButton>
|
||||
<span>Page <span style={mono}>{page}</span> of <span style={mono}>{pages}</span></span>
|
||||
<IconButton size="sm" title="Next page" disabled={page >= pages}><Ico n="ChevronRight" s={15} /></IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaInfoDialog({ open, onClose }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 50 }} onClick={onClose}>
|
||||
<div onClick={(e) => 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)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 18px", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Media Info</span>
|
||||
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
|
||||
</div>
|
||||
<div style={{ padding: 18, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<dl style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "6px 12px", margin: 0 }}>
|
||||
<dt style={{ color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em" }}>Kind</dt>
|
||||
<dd style={{ margin: 0, color: "var(--text-primary)", font: "var(--text-sm)/1 var(--font-sans)" }}>Movie</dd>
|
||||
<dt style={{ color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em" }}>Library</dt>
|
||||
<dd style={{ margin: 0, color: "var(--text-primary)", font: "var(--text-sm)/1 var(--font-sans)" }}>Movies (Plex)</dd>
|
||||
<dt style={{ color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em" }}>Duration</dt>
|
||||
<dd style={{ ...mono, margin: 0, color: "var(--text-primary)", fontSize: "var(--text-sm)" }}>7:37</dd>
|
||||
<dt style={{ color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em" }}>Resolution</dt>
|
||||
<dd style={{ ...mono, margin: 0, color: "var(--text-primary)", fontSize: "var(--text-sm)" }}>1920×1080</dd>
|
||||
<dt style={{ color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em" }}>Aspect Ratio</dt>
|
||||
<dd style={{ margin: 0, color: "var(--text-primary)", font: "var(--text-sm)/1 var(--font-sans)" }}>1.78:1</dd>
|
||||
</dl>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<h4 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Streams</h4>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-sm)", overflow: "hidden" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{["#", "Kind", "Codec", "Language", "Title"].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "6px 10px", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--text-disabled)", background: "var(--ctv-bg-sunken)", borderBottom: "1px solid var(--border-hairline)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{STREAMS.map((s) => (
|
||||
<tr key={s.kind} style={{ borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<td style={{ ...mono, padding: "6px 10px", fontSize: "var(--text-sm)", color: "var(--text-secondary)" }}>{s.i}</td>
|
||||
<td style={{ padding: "6px 10px", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.kind}</td>
|
||||
<td style={{ ...mono, padding: "6px 10px", fontSize: "var(--text-sm)", color: "var(--text-secondary)" }}>{s.codec}</td>
|
||||
<td style={{ padding: "6px 10px", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{s.lang}</td>
|
||||
<td style={{ padding: "6px 10px", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{s.title}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaDetail() {
|
||||
const [kind, setKind] = React.useState("movie");
|
||||
const [infoOpen, setInfoOpen] = React.useState(false);
|
||||
const d = KINDS[kind];
|
||||
|
||||
const Segmented = () => (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{Object.keys(KINDS).map((k) => {
|
||||
const on = k === kind;
|
||||
return (
|
||||
<button key={k} type="button" onClick={() => setKind(k)}
|
||||
style={{ display: "inline-flex", alignItems: "center", height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{KINDS[k].label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* preview switcher — not part of the live screen, lets this mockup demo all 4 detail kinds */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />}>{d.back}</Button>
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--text-disabled)" }}>Preview kind</span>
|
||||
<Segmented />
|
||||
<span style={{ flex: 1 }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px" }}>
|
||||
<div style={{ position: "relative", display: "flex", flexDirection: "column", gap: 14, maxWidth: 1040, margin: "0 auto" }}>
|
||||
{d.fanart && (
|
||||
<div style={{ position: "absolute", inset: "-8px -8px auto -8px", height: 220, borderRadius: "var(--radius-md)", pointerEvents: "none", zIndex: 0, background: "linear-gradient(160deg, var(--ctv-accent-soft), transparent 70%)", opacity: 0.5, maskImage: "linear-gradient(to bottom, black, transparent)", WebkitMaskImage: "linear-gradient(to bottom, black, transparent)" }} />
|
||||
)}
|
||||
|
||||
<div style={{ position: "relative", zIndex: 1, display: "flex", gap: 16, flexWrap: "wrap" }}>
|
||||
{d.poster ? <PosterThumb size={200} radius="var(--radius-sm)" hue={(d.title.length * 29) % 360} /> : null}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 260, flex: 1 }}>
|
||||
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-xl)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{d.title}</h2>
|
||||
{d.subtitle ? <div style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{d.subtitle}</div> : null}
|
||||
{d.plot ? <p style={{ margin: 0, color: "var(--text-primary)", lineHeight: 1.5, maxWidth: "70ch", font: "var(--text-sm)/1.5 var(--font-sans)" }}>{d.plot}</p> : null}
|
||||
{d.state === "FileNotFound" && (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "6px 10px", borderRadius: "var(--radius-sm)", background: "var(--ctv-error-soft)", color: "var(--status-error)", font: "var(--text-xs)/1.2 var(--font-sans)", width: "fit-content" }}>
|
||||
<Ico n="TriangleAlert" s={14} /><span>File not found</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
|
||||
{d.actions.includes("info") && <Button size="sm" variant="secondary" startIcon={<Ico n="Info" s={14} />} onClick={() => setInfoOpen(true)}>Media Info</Button>}
|
||||
{d.actions.includes("troubleshoot") && <Button size="sm" variant="secondary" startIcon={<Ico n="Stethoscope" s={14} />}>Troubleshoot Playback</Button>}
|
||||
{d.actions.includes("scan") && (
|
||||
<React.Fragment>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ScanSearch" s={14} />}>Quick scan</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ScanSearch" s={14} />}>Deep scan</Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{d.actions.includes("addto") && <Button size="sm" variant="primary" startIcon={<Ico n="Plus" s={14} />} endIcon={<Ico n="ChevronDown" s={13} />}>Add to…</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.chips.length > 0 && (
|
||||
<Card padded>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{d.chips.map((c) => <ChipRow key={c.label} label={c.label} values={c.values} />)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ActorsRow actors={d.actors} />
|
||||
|
||||
{d.path ? <div style={{ ...mono, position: "relative", zIndex: 1, fontSize: "var(--text-xs)", color: "var(--text-secondary)", wordBreak: "break-all" }}>{d.path}</div> : null}
|
||||
|
||||
{d.grid && <ChildGrid heading={d.grid.heading} kind={d.grid.kind} count={d.grid.count} page={d.grid.page} pages={d.grid.pages} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaInfoDialog open={infoOpen} onClose={() => setInfoOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVMediaDetail = MediaDetail;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{ ...flushRow(first), background: hover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Ico n="Layers" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--text-primary)",
|
||||
textDecoration: hover ? "underline" : "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{mc.name}
|
||||
</button>
|
||||
<Badge tone="neutral">
|
||||
{mc.items} item{mc.items === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
<IconButton size="sm" title="Delete" variant="ghost">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditItemRow({ item, first }) {
|
||||
const [checked, setChecked] = React.useState(item.scheduleAsGroup);
|
||||
return (
|
||||
<div style={flushRow(first)}>
|
||||
<Ico n="Layers" s={15} color="var(--ctv-accent)" />
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<Badge tone="neutral">{item.kind === "smart" ? "Smart" : "Manual"}</Badge>
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 7, cursor: "pointer" }} title="Schedule as group">
|
||||
<Switch checked={checked} onChange={setChecked} size="sm" />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap" }}>Schedule as group</span>
|
||||
</label>
|
||||
<IconButton size="sm" title={`Remove ${item.name}`} variant="ghost">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>
|
||||
All multi-collections
|
||||
</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{existing ? "Edit multi-collection" : "New multi-collection"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>
|
||||
{existing ? "Save multi-collection" : "Add multi-collection"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16, maxWidth: 720 }}>
|
||||
<Card title="Multi-collection">
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Multi-collection name" />
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, marginTop: 16 }}>
|
||||
<Select label="Collections" value={colSel} onChange={(e) => setColSel(e.target.value)} options={collectionOptions} fullWidth />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />} disabled={colSel === ""}>
|
||||
Add Collection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, marginTop: 12 }}>
|
||||
<Select label="Smart Collections" value={smartSel} onChange={(e) => setSmartSel(e.target.value)} options={smartOptions} fullWidth />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />} disabled={smartSel === ""}>
|
||||
Add Smart Collection
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false}>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ padding: "28px 16px", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
No collections added yet.
|
||||
</div>
|
||||
) : (
|
||||
items.map((item, i) => <EditItemRow key={item.kind + ":" + item.name} item={item} first={i === 0} />)
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <EditorPane existing={editing.kind === "edit" ? editing.item : null} onBack={() => setEditing(null)} />;
|
||||
}
|
||||
|
||||
if (demo === "loading") {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 10 }}>
|
||||
<Spinner size={18} />
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Loading multi-collections…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = demo === "empty" ? [] : MULTI_COLLECTIONS;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{rows.length} multi-collections</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
{/* mockup-only state switcher — not part of the live screen */}
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "populated", l: "Populated" }, { v: "empty", l: "Empty" }, { v: "loading", l: "Loading" }].map((o) => {
|
||||
const on = o.v === demo;
|
||||
return (
|
||||
<button
|
||||
key={o.v}
|
||||
type="button"
|
||||
onClick={() => setDemo(o.v)}
|
||||
style={{
|
||||
height: 24,
|
||||
padding: "0 9px",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-xs)",
|
||||
cursor: "pointer",
|
||||
background: on ? "var(--ctv-surface-3)" : "transparent",
|
||||
color: on ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-2xs)/1 var(--font-sans)`,
|
||||
}}
|
||||
>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={() => setEditing({ kind: "new" })}>
|
||||
New multi-collection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ padding: "28px 16px", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
No multi-collections yet.
|
||||
</div>
|
||||
) : (
|
||||
rows.map((mc, i) => <ListRow key={mc.id} mc={mc} first={i === 0} onOpen={() => setEditing({ kind: "edit", item: mc })} />)
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVMultiCollections = MultiCollections;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header section */}
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 8, padding: "24px 28px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div>
|
||||
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 6 }}>Jellyfin path replacements</div>
|
||||
<h2 style={{ font: "var(--weight-bold) var(--text-2xl)/1.2 var(--font-sans)", color: "var(--text-primary)", margin: 0, marginBottom: 8 }}>Edit path replacements</h2>
|
||||
<p style={{ font: "var(--text-sm)/1.5 var(--font-sans)", color: "var(--text-secondary)", margin: 0 }}>Map Jellyfin media paths onto the paths ErsatzTV can reach.</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
||||
<Button onClick={() => {}} startIcon={<Ico n="ArrowLeft" s={14} />} variant="ghost">Back to Jellyfin</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content: two-column grid */}
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden", gap: 0 }}>
|
||||
{/* Left panel: list of replacements */}
|
||||
<section style={{ display: "flex", flexDirection: "column", width: 280, borderRight: "1px solid var(--border-hairline)", background: "var(--ctv-bg)", overflow: "hidden", flex: "0 0 auto" }} aria-label="Path replacements">
|
||||
{/* Section header */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Replacements</span>
|
||||
<Button onClick={() => {}} size="sm" startIcon={<Ico n="Plus" s={14} />} variant="ghost" disabled={isSaving}>Add</Button>
|
||||
</div>
|
||||
|
||||
{/* List of replacements */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "8px" }} role="list" aria-label="Path replacement rows">
|
||||
{mockReplacements.map((row) => {
|
||||
const isActive = row.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
role="listitem"
|
||||
onClick={() => 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)"
|
||||
}}
|
||||
>
|
||||
<strong style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", wordBreak: "break-all" }}>{row.remotePath}</strong>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
<Ico n="ArrowRight" s={12} color="var(--text-disabled)" />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.localPath}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right panel: edit form */}
|
||||
<section style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "auto", padding: "24px 28px", background: "var(--surface-app)" }} aria-label="Path replacement editor">
|
||||
{selected ? (
|
||||
<Card title={<h3 style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)", margin: 0 }}>Edit replacement</h3>} padded>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<Input
|
||||
label="Jellyfin Path"
|
||||
value={selected.remotePath}
|
||||
disabled={isSaving}
|
||||
onChange={(e) => handleRemoteChange(e.target.value)}
|
||||
placeholder="/mnt/media"
|
||||
error={selected.remotePath.trim() ? null : "Jellyfin Path is required"}
|
||||
/>
|
||||
<Input
|
||||
label="Local Path"
|
||||
value={selected.localPath}
|
||||
disabled={isSaving}
|
||||
onChange={(e) => handleLocalChange(e.target.value)}
|
||||
placeholder="/var/lib/jellyfin/media"
|
||||
error={selected.localPath.trim() ? null : "Local Path is required"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Select or add a path replacement</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 12, padding: "16px 28px", borderTop: "1px solid var(--border-hairline)", background: "var(--surface-card)", flex: "0 0 auto" }}>
|
||||
<Button onClick={handleDiscard} disabled={!isDirty || isSaving} variant="ghost">Discard</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
startIcon={<Ico n="Check" s={14} />}
|
||||
variant="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVPathReplacementsEdit = PathReplacementsEdit;
|
||||
})();
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", gap: 24, padding: "14px 0", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ flex: "0 0 200px" }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 4, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-faint)", maxWidth: 260 }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: "0 0 340px", minWidth: 0 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxList({ items, selected, onToggle }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((it) => (
|
||||
<Checkbox key={it.id} checked={selected.includes(it.id)} onChange={() => onToggle(it.id)} label={it.name} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Download" s={14} />}>Download Results</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="DownloadCloud" s={14} />}>Download Media Sample</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="Stethoscope" s={14} color="var(--text-disabled)" />
|
||||
Media #14582
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16, maxWidth: 900 }}>
|
||||
{/* success notice */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-md)", background: "var(--ctv-ok-soft)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="CheckCircle2" s={16} color="var(--status-ok)" />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>FFmpeg troubleshooting process exited successfully</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Tooltip label="Dismiss"><span style={{ cursor: "pointer", color: "var(--text-faint)" }}><Ico n="X" s={14} /></span></Tooltip>
|
||||
</div>
|
||||
|
||||
<Card padded title="Movie Settings — Alien (1979)" subtitle="Preview a media item">
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Row label="FFmpeg Profile">
|
||||
<Select value={profile} onChange={(e) => setProfile(e.target.value)} options={PROFILE_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Stream Selector">
|
||||
<Select value={selector} onChange={(e) => setSelector(e.target.value)} options={SELECTOR_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Subtitle">
|
||||
<Select value={selector === "" ? subtitle : ""} disabled={selector !== ""} onChange={(e) => setSubtitle(e.target.value)} options={SUBTITLE_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Watermarks">
|
||||
<CheckboxList items={WATERMARKS} selected={watermarks} onToggle={(id) => toggle(watermarks, setWatermarks, id)} />
|
||||
</Row>
|
||||
<Row label="Graphics Elements">
|
||||
<CheckboxList items={GRAPHICS} selected={graphics} onToggle={(id) => toggle(graphics, setGraphics, id)} />
|
||||
</Row>
|
||||
<Row label="Start From Beginning">
|
||||
<Checkbox checked={fromStart} onChange={setFromStart} label="Start from the beginning" />
|
||||
</Row>
|
||||
<Row label="Seek Seconds">
|
||||
<Input type="number" value={String(seek)} disabled={fromStart} onChange={(e) => setSeek(Number(e.target.value) || 0)} fullWidth />
|
||||
</Row>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded title="Preview" subtitle="Live segmenter output">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Button variant="primary" startIcon={<Ico n="PlayCircle" s={15} />} disabled={isBusy}>Play</Button>
|
||||
{isBusy && (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8, font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Spinner size={15} tone="accent" />
|
||||
{phase === "starting" ? "Starting…" : "Transcoding…"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, maxWidth: 720, aspectRatio: "16 / 9", width: "100%", background: "#000", borderRadius: "var(--radius-md)", position: "relative", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", border: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, color: "rgba(255,255,255,0.55)" }}>
|
||||
<Ico n="Film" s={30} color="rgba(255,255,255,0.4)" />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)" }}>HLS preview stream</span>
|
||||
</div>
|
||||
<div style={{ position: "absolute", top: 10, left: 10 }}>
|
||||
<StatusDot status="live" pulse size={8} label="Live" />
|
||||
</div>
|
||||
<div style={{ position: "absolute", bottom: 10, right: 12, ...mono, fontSize: "var(--text-2xs)", color: "rgba(255,255,255,0.7)" }}>00:45:47 / 01:56:32</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padded
|
||||
title={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
Logs
|
||||
<Badge tone="neutral" solid={false}>
|
||||
<span style={{ ...mono, color: speedColor }}>Speed: {speed}x</span>
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={16}
|
||||
value={LOG_TAIL}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
boxSizing: "border-box",
|
||||
padding: "12px 14px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
color: "var(--text-secondary)",
|
||||
font: "var(--text-xs)/1.6 var(--font-mono)",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVPlaybackTroubleshooting = PlaybackTroubleshooting;
|
||||
})();
|
||||
@@ -0,0 +1,206 @@
|
||||
// Playlists screen — a collapsible group/playlist tree (list mode) that drills into a
|
||||
// per-playlist item editor (list+detail: ordered item rows on the left, the selected
|
||||
// item's config form on the right-below, plus a playout preview table). Mirrors the
|
||||
// flush-row list idiom used by Collections/Settings, not the dense Channels table.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Switch } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 1, name: "Default", isSystem: true, count: 1 },
|
||||
{ id: 2, name: "Holiday Specials", isSystem: false, count: 2 },
|
||||
{ id: 3, name: "Nightly Rotation", isSystem: false, count: 3 },
|
||||
];
|
||||
|
||||
const PLAYLISTS = {
|
||||
1: [{ id: 101, name: "Bumpers", isSystem: true }],
|
||||
2: [
|
||||
{ id: 201, name: "Halloween Marathon", isSystem: false },
|
||||
{ id: 202, name: "Winter Classics", isSystem: false },
|
||||
],
|
||||
3: [
|
||||
{ id: 301, name: "80s Sitcom Block", isSystem: false },
|
||||
{ id: 302, name: "Late Night Anime", isSystem: false },
|
||||
{ id: 303, name: "Weekend Cartoons", isSystem: false },
|
||||
],
|
||||
};
|
||||
|
||||
// detail view = "301 · 80s Sitcom Block"
|
||||
const ITEMS = [
|
||||
{ key: "i1", type: "Television Show", name: "Cheers", order: "Season, Episode", count: "", playAll: true, epg: true },
|
||||
{ key: "i2", type: "Collection", name: "80s Commercial Bumpers", order: "Shuffle", count: "3", playAll: false, epg: false },
|
||||
{ key: "i3", type: "Television Show", name: "Family Ties", order: "Chronological", count: "", playAll: true, epg: true },
|
||||
{ key: "i4", type: "Multi Collection", name: "Retro Sitcom Mix", order: "Shuffle", count: "", playAll: true, epg: true },
|
||||
];
|
||||
|
||||
const PREVIEW = [
|
||||
{ start: "18:00:00", finish: "18:24:10", title: "Cheers — S1E1 — Give Me a Ring Sometime", duration: "00:24:10" },
|
||||
{ start: "18:24:10", finish: "18:24:40", title: "80s Commercial Bumpers — Pizza Hut Jingle", duration: "00:00:30" },
|
||||
{ start: "18:24:40", finish: "18:24:55", title: "80s Commercial Bumpers — Time Cube Bumper", duration: "00:00:15" },
|
||||
{ start: "18:24:55", finish: "18:25:10", title: "80s Commercial Bumpers — Local News Promo", duration: "00:00:15" },
|
||||
{ start: "18:25:10", finish: "18:49:44", title: "Family Ties — S1E1 — Pilot", duration: "00:24:34" },
|
||||
{ start: "18:49:44", finish: "19:14:02", title: "Retro Sitcom Mix — Perfect Strangers S2E4", duration: "00:24:18" },
|
||||
];
|
||||
|
||||
const row = { display: "flex", alignItems: "center", gap: 10, padding: "0 16px", height: 46, borderTop: "1px solid var(--border-hairline)" };
|
||||
const rowTitle = { flex: 1, minWidth: 0, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--ctv-accent)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
|
||||
const countTxt = { font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", whiteSpace: "nowrap" };
|
||||
const toggle = { display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap" };
|
||||
|
||||
function ActionBar({ children }) {
|
||||
return <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>{children}</div>;
|
||||
}
|
||||
|
||||
function ListView({ onOpen }) {
|
||||
const [collapsed, setCollapsed] = React.useState({ 1: false, 2: false, 3: false });
|
||||
const toggleGroup = (id) => setCollapsed((c) => ({ ...c, [id]: !c[id] }));
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ActionBar>
|
||||
<Badge tone="neutral">{GROUPS.length} groups</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add group</Button>
|
||||
<Button size="sm" variant="primary" startIcon={<Ico n="Plus" s={14} />}>Add playlist</Button>
|
||||
</ActionBar>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{GROUPS.map((g, gi) => {
|
||||
const isCollapsed = collapsed[g.id];
|
||||
const playlists = PLAYLISTS[g.id] || [];
|
||||
return (
|
||||
<div key={g.id}>
|
||||
<div style={{ ...row, borderTop: gi === 0 ? "none" : row.borderTop }}>
|
||||
<IconButton size="sm" variant="ghost" title={isCollapsed ? "Expand" : "Collapse"} onClick={() => toggleGroup(g.id)}>
|
||||
<Ico n={isCollapsed ? "ChevronRight" : "ChevronDown"} s={14} />
|
||||
</IconButton>
|
||||
<Ico n="ListMusic" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" style={{ ...rowTitle, color: "var(--text-primary)" }} onClick={() => toggleGroup(g.id)}>{g.name}</button>
|
||||
{g.isSystem && <Badge tone="neutral">System</Badge>}
|
||||
<span style={countTxt}>{playlists.length} playlist{playlists.length === 1 ? "" : "s"}</span>
|
||||
<IconButton size="sm" variant="ghost" title="Rename group" disabled={g.isSystem}><Ico n="Pencil" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete group" disabled={g.isSystem}><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (playlists.length === 0 ? (
|
||||
<div style={{ ...row, paddingLeft: 48 }}>
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-faint)" }}>No playlists in this group.</span>
|
||||
</div>
|
||||
) : playlists.map((p) => (
|
||||
<div key={p.id} style={{ ...row, paddingLeft: 48 }}>
|
||||
<Ico n="ListVideo" s={15} color="var(--text-disabled)" />
|
||||
<button type="button" style={rowTitle} onClick={() => onOpen(p)}>{p.name}</button>
|
||||
{p.isSystem && <Badge tone="neutral">System</Badge>}
|
||||
<IconButton size="sm" variant="ghost" title="Edit" disabled={p.isSystem} onClick={() => onOpen(p)}><Ico n="Pencil" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete" disabled={p.isSystem}><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
)))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorView({ playlist, onBack }) {
|
||||
const [selected, setSelected] = React.useState("i1");
|
||||
const [name, setName] = React.useState(playlist.name);
|
||||
const item = ITEMS.find((i) => i.key === selected);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ActionBar>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All playlists</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{playlist.name}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Eye" s={14} />}>Preview</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>
|
||||
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}>Save playlist</Button>
|
||||
</ActionBar>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card title="Playlist">
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Card>
|
||||
|
||||
<Card title="Playlist items" padded={false}>
|
||||
{ITEMS.map((it, i) => (
|
||||
<div key={it.key} style={{ ...row, borderTop: i === 0 ? "none" : row.borderTop, background: it.key === selected ? "var(--ctv-accent-soft)" : "transparent" }}>
|
||||
<Ico n="ListVideo" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" style={rowTitle} onClick={() => setSelected(it.key)}>{it.name}</button>
|
||||
<Badge tone={it.key === selected ? "accent" : "neutral"}>{it.type}</Badge>
|
||||
<span style={countTxt}>{it.order}</span>
|
||||
<span style={{ ...countTxt, width: 20, textAlign: "right" }}>{it.count || (it.playAll ? "" : "1")}</span>
|
||||
<label style={toggle} title="Play all"><Switch checked={it.playAll} size="sm" onChange={() => {}} /><span>Play all</span></label>
|
||||
<label style={toggle} title="Show in EPG"><Switch checked={it.epg} size="sm" onChange={() => {}} /><span>Show in EPG</span></label>
|
||||
<IconButton size="sm" variant="ghost" title="Copy"><Ico n="Copy" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Move up" disabled={i === 0}><Ico n="ArrowUp" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Move down" disabled={i === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{item && (
|
||||
<Card title="Playlist item">
|
||||
<Select label="Collection Type" value={item.type} options={["Collection", "Television Show", "Television Season", "Artist", "Multi Collection", "Smart Collection", "Movie", "Episode", "Music Video", "Other Video", "Song", "Image"]} />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select label={item.type} value={item.name} options={[item.name, "Frasier", "Perfect Strangers", "Night Court"]} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select label="Playback Order" value={item.order} options={["Chronological", "Shuffle", "Random", "Shuffle In Order", "Season, Episode"]} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input label="Count" value={item.count} placeholder="All" type="number" onChange={() => {}} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Playlist playout preview" padded={false}>
|
||||
<div style={{ overflow: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Start</th>
|
||||
<th style={th}>Finish</th>
|
||||
<th style={th}>Media Item</th>
|
||||
<th style={{ ...th, textAlign: "right" }}>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PREVIEW.map((r, i) => (
|
||||
<tr key={i} style={{ borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<td style={{ ...td, ...mono, width: 90 }}>{r.start}</td>
|
||||
<td style={{ ...td, ...mono, width: 90 }}>{r.finish}</td>
|
||||
<td style={td}>{r.title}</td>
|
||||
<td style={{ ...td, ...mono, textAlign: "right", width: 90 }}>{r.duration}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const th = { textAlign: "left", padding: "0 16px", height: 32, 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)" };
|
||||
const td = { padding: "0 16px", height: 40, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
|
||||
|
||||
function Playlists() {
|
||||
const [editing, setEditing] = React.useState(null);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{editing ? <EditorView playlist={editing} onBack={() => setEditing(null)} /> : <ListView onOpen={setEditing} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVPlaylists = Playlists;
|
||||
})();
|
||||
@@ -0,0 +1,340 @@
|
||||
// Playout Schedules screen — priority-ordered recurrence-rule editors for a single playout:
|
||||
// "Templates" (block playouts, template + deco template + preview calendar) and "Alternate
|
||||
// schedules" (classic playouts, schedule + catch-all default row). Master list + detail-edit
|
||||
// pane, day/month chip pickers, and an optional month preview grid.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Select, Checkbox } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const DAYS_OF_WEEK = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const SHORT_DAY = (d) => d.slice(0, 3);
|
||||
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
const MONTH_NAME = (m, style) =>
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][m - 1]
|
||||
? style === "short"
|
||||
? ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
|
||||
: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][m - 1]
|
||||
: "";
|
||||
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||
|
||||
// ----- mock data -----
|
||||
|
||||
const TEMPLATE_ITEMS = [
|
||||
{ id: 1, name: "Weekday / Daytime block", deco: "None", dow: "Mon, Tue, Wed, Thu, Fri", dom: "any", months: "any" },
|
||||
{ id: 2, name: "Weekend / Cartoon marathon", deco: "Weekend Bug", dow: "Sat, Sun", dom: "any", months: "any" },
|
||||
{ id: 3, name: "Holiday / Christmas block", deco: "Holiday Overlay", dow: "any", dom: "24-26", months: "Dec" },
|
||||
{ id: 4, name: "Prime Time / Default", deco: "None", dow: "any", dom: "any", months: "any", isDefault: true },
|
||||
];
|
||||
|
||||
const ALT_ITEMS = [
|
||||
{ id: 1, name: "Marathon Weekend", dow: "Sat, Sun", dom: "any", months: "any" },
|
||||
{ id: 2, name: "Holiday Programming", dow: "any", dom: "20-31", months: "Dec" },
|
||||
{ id: 3, name: "Standard Rotation", dow: "any", dom: "any", months: "any", isDefault: true },
|
||||
];
|
||||
|
||||
const PREVIEW_WEEKS = [
|
||||
[null, null, null, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, 9, 10, 11],
|
||||
[12, 13, 14, 15, 16, 17, 18],
|
||||
[19, 20, 21, 22, 23, 24, 25],
|
||||
[26, 27, 28, 29, 30, 31, null],
|
||||
];
|
||||
const WEEKEND_DAYS = new Set([1, 2, 8, 9, 15, 16, 22, 23, 29, 30]);
|
||||
const HOLIDAY_DAYS = new Set([24, 25, 26]);
|
||||
function previewWinner(day) {
|
||||
if (day === null) return null;
|
||||
if (HOLIDAY_DAYS.has(day)) return "Holiday / Christmas block";
|
||||
if (WEEKEND_DAYS.has(day)) return "Weekend / Cartoon marathon";
|
||||
return "Weekday / Daytime block";
|
||||
}
|
||||
|
||||
const rowLabel = { padding: "0 var(--pad-cell-x)", height: 30, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", textAlign: "left", whiteSpace: "nowrap", position: "sticky", top: 0, background: "var(--surface-card)", borderBottom: "1px solid var(--border-hairline)" };
|
||||
const td = { padding: "0 var(--pad-cell-x)", height: 46, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
|
||||
|
||||
function Chip({ label, active, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 30, height: 26, padding: "0 8px",
|
||||
borderRadius: "var(--radius-sm)", cursor: "pointer",
|
||||
border: active ? "1px solid var(--ctv-accent)" : "1px solid var(--border-control)",
|
||||
background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
|
||||
color: active ? "var(--ctv-accent)" : "var(--text-secondary)",
|
||||
font: `${active ? "var(--weight-semibold)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)`,
|
||||
transition: "background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, padding: "12px var(--pad-card)", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: 160, flex: "0 0 160px", paddingTop: 6, font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecurrenceEditor({ disabled, disabledNote }) {
|
||||
const [limitRange, setLimitRange] = React.useState(false);
|
||||
const [dow, setDow] = React.useState(new Set(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]));
|
||||
const [dom, setDom] = React.useState(new Set(DAYS_OF_MONTH));
|
||||
const [mo, setMo] = React.useState(new Set(MONTHS));
|
||||
const toggleSet = (set, setter, v) => {
|
||||
const next = new Set(set);
|
||||
next.has(v) ? next.delete(v) : next.add(v);
|
||||
setter(next);
|
||||
};
|
||||
return (
|
||||
<React.Fragment>
|
||||
{disabled && disabledNote && (
|
||||
<div style={{ margin: "12px var(--pad-card) 0", padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", display: "flex", gap: 8, alignItems: "flex-start" }}>
|
||||
<Ico n="Info" s={14} color="var(--text-disabled)" />
|
||||
<span>{disabledNote}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FieldRow label="Limit to date range">
|
||||
<Checkbox checked={limitRange} disabled={disabled} onChange={() => setLimitRange((v) => !v)} label="Restrict to a start/end day" />
|
||||
</FieldRow>
|
||||
|
||||
{limitRange && (
|
||||
<React.Fragment>
|
||||
<FieldRow label="Start day">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Select disabled={disabled} value="12" options={MONTHS.map((m) => ({ value: String(m), label: MONTH_NAME(m, "long") }))} />
|
||||
<Select disabled={disabled} value="20" options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))} />
|
||||
<Select disabled={disabled} value="" options={[{ value: "", label: "Any" }, { value: "2026", label: "2026" }, { value: "2027", label: "2027" }]} />
|
||||
</div>
|
||||
</FieldRow>
|
||||
<FieldRow label="End day">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Select disabled={disabled} value="1" options={MONTHS.map((m) => ({ value: String(m), label: MONTH_NAME(m, "long") }))} />
|
||||
<Select disabled={disabled} value="2" options={DAYS_OF_MONTH.map((d) => ({ value: String(d), label: String(d) }))} />
|
||||
<Select disabled={disabled} value="" options={[{ value: "", label: "Any" }, { value: "2026", label: "2026" }, { value: "2027", label: "2027" }]} />
|
||||
</div>
|
||||
</FieldRow>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<FieldRow label="Days of the week">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{DAYS_OF_WEEK.map((d) => (
|
||||
<Chip key={d} label={SHORT_DAY(d)} active={dow.has(d)} onClick={() => toggleSet(dow, setDow, d)} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => setDow(new Set(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]))}>Weekdays</Button>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => setDow(new Set(["Saturday", "Sunday"]))}>Weekends</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Days of the month">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, maxWidth: 560 }}>
|
||||
{DAYS_OF_MONTH.map((d) => (
|
||||
<Chip key={d} label={String(d)} active={dom.has(d)} onClick={() => toggleSet(dom, setDom, d)} />
|
||||
))}
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Months">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{MONTHS.map((m) => (
|
||||
<Chip key={m} label={MONTH_NAME(m, "short")} active={mo.has(m)} onClick={() => toggleSet(mo, setMo, m)} />
|
||||
))}
|
||||
</div>
|
||||
</FieldRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleTable({ mode, items, selected, onSelect }) {
|
||||
const primaryLabel = mode === "templates" ? "Template" : "Schedule";
|
||||
return (
|
||||
<Card padded={false} title={primaryLabel + "s"}>
|
||||
<div style={{ overflow: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={rowLabel}>{primaryLabel}</th>
|
||||
<th style={rowLabel}>Days of the week</th>
|
||||
<th style={rowLabel}>Days of the month</th>
|
||||
<th style={rowLabel}>Months</th>
|
||||
<th style={{ ...rowLabel, textAlign: "right" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it, i) => {
|
||||
const isSel = selected === it.id;
|
||||
return (
|
||||
<tr
|
||||
key={it.id}
|
||||
onClick={() => onSelect(it.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)",
|
||||
background: isSel ? "var(--ctv-accent-soft)" : "transparent",
|
||||
transition: "background var(--dur-fast)",
|
||||
}}
|
||||
>
|
||||
<td style={td}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)" }}>{it.name}</span>
|
||||
{it.isDefault && <React.Fragment>{" "}<Badge tone="accent">Default</Badge></React.Fragment>}
|
||||
</td>
|
||||
<td style={{ ...td, color: "var(--text-secondary)" }}>{it.dow}</td>
|
||||
<td style={{ ...td, color: "var(--text-secondary)" }}>{it.dom}</td>
|
||||
<td style={{ ...td, color: "var(--text-secondary)" }}>{it.months}</td>
|
||||
<td style={{ ...td, width: 130 }}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="sm" title="Move up" variant="ghost" disabled={i === 0}><Ico n="ArrowUp" s={14} /></IconButton>
|
||||
<IconButton size="sm" title="Move down" variant="ghost" disabled={i === items.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
|
||||
<IconButton size="sm" title="Delete" variant="ghost" disabled={items.length === 1}><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCalendar() {
|
||||
return (
|
||||
<Card padded={false} title="Preview calendar">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "12px 0" }}>
|
||||
<IconButton size="sm" variant="ghost" title="Previous month"><Ico n="ArrowLeft" s={14} /></IconButton>
|
||||
<span style={{ minWidth: 160, textAlign: "center", font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>December 2026</span>
|
||||
<IconButton size="sm" variant="ghost" title="Next month"><Ico n="ArrowRight" s={14} /></IconButton>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4, padding: "0 var(--pad-card) var(--pad-card)" }}>
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => (
|
||||
<div key={d} style={{ textAlign: "center", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "0 0 6px" }}>{d}</div>
|
||||
))}
|
||||
{PREVIEW_WEEKS.flat().map((day, idx) => {
|
||||
if (day === null) return <div key={"b" + idx} />;
|
||||
const winner = previewWinner(day);
|
||||
const isHoliday = HOLIDAY_DAYS.has(day);
|
||||
const isWeekend = WEEKEND_DAYS.has(day);
|
||||
return (
|
||||
<div key={day} style={{
|
||||
minHeight: 54, borderRadius: "var(--radius-sm)", padding: "4px 6px",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
background: isHoliday ? "var(--ctv-warn-soft)" : isWeekend ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
|
||||
}}>
|
||||
<div style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{day}</div>
|
||||
{winner && <div style={{ marginTop: 4, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>{winner}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutScheduleEditors() {
|
||||
const [mode, setMode] = React.useState("templates"); // "templates" | "alternates"
|
||||
const items = mode === "templates" ? TEMPLATE_ITEMS : ALT_ITEMS;
|
||||
const [selected, setSelected] = React.useState(items[0].id);
|
||||
const [showPreview, setShowPreview] = React.useState(true);
|
||||
|
||||
React.useEffect(() => { setSelected(items[0].id); }, [mode]); // eslint-disable-line
|
||||
|
||||
const sel = items.find((i) => i.id === selected) || null;
|
||||
const isDefaultSelected = sel && sel.isDefault;
|
||||
|
||||
const Segmented = () => (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "templates", l: "Templates" }, { v: "alternates", l: "Alternate schedules" }].map((o) => {
|
||||
const on = o.v === mode;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setMode(o.v)}
|
||||
style={{ display: "inline-flex", alignItems: "center", height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />}>All playouts</Button>
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
Weeknight Movies <span style={{ color: "var(--text-disabled)" }}>/</span> {mode === "templates" ? "Templates" : "Alternate schedules"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add</Button>
|
||||
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}>Save</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Segmented />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{mode === "templates"
|
||||
? "In priority order from top to bottom. Each template optionally applies a deco template."
|
||||
: "In priority order from top to bottom. The last entry always matches every day and month as the catch-all default, and its schedule becomes the playout's default schedule."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScheduleTable mode={mode} items={items} selected={selected} onSelect={setSelected} />
|
||||
|
||||
{mode === "templates" && (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="CalendarDays" s={14} />} onClick={() => setShowPreview((v) => !v)}>
|
||||
{showPreview ? "Hide preview calendar" : "Preview calendar"}
|
||||
</Button>
|
||||
</div>
|
||||
{showPreview && <PreviewCalendar />}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{sel && (
|
||||
<Card padded={false} title={mode === "templates" ? "Edit template" : "Edit schedule"}>
|
||||
<FieldRow label={mode === "templates" ? "Template" : "Schedule"}>
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<Select
|
||||
value={String(sel.id)}
|
||||
options={items.map((i) => ({ value: String(i.id), label: i.name }))}
|
||||
/>
|
||||
</div>
|
||||
</FieldRow>
|
||||
{mode === "templates" && (
|
||||
<FieldRow label="Deco template">
|
||||
<div style={{ maxWidth: 360 }}>
|
||||
<Select
|
||||
value={sel.deco}
|
||||
options={[{ value: "None", label: "None" }, { value: "Weekend Bug", label: "Marketing / Weekend Bug" }, { value: "Holiday Overlay", label: "Seasonal / Holiday Overlay" }]}
|
||||
/>
|
||||
</div>
|
||||
</FieldRow>
|
||||
)}
|
||||
<RecurrenceEditor
|
||||
disabled={mode === "alternates" && isDefaultSelected}
|
||||
disabledNote="The default entry always matches every day; reorder it above another entry to scope it."
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVPlayoutScheduleEditors = PlayoutScheduleEditors;
|
||||
})();
|
||||
@@ -1,201 +1,231 @@
|
||||
// Playouts screen — GROUND-UP: a broadcast "now / next" monitor. Channel picker
|
||||
// strip, live preview + progress, up-next, the playout timeline, and upcoming items.
|
||||
// Playouts screen — a broadcast "now / next" monitor. Filterable playout rail on the
|
||||
// left; on the right, an on-air hero, up-next + playout-detail cards, a timeline, and
|
||||
// an upcoming-items list. Mirrors PlayoutsScreen.tsx (ctv-playouts-* layout classes).
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Card, Button, IconButton, Badge, StatusDot, ChannelLogo, Switch, Tooltip, Input } = NS;
|
||||
const { Badge, Button, Card, ChannelLogo, IconButton, Input, ProgressBar, StatusDot, Switch, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const toMin = (t) => { const [h, m] = t.split(":").map(Number); return h * 60 + m; };
|
||||
|
||||
function Timeline() {
|
||||
const items = D.playoutItems;
|
||||
const start = toMin(items[0].start), end = toMin(items[items.length - 1].finish);
|
||||
const span = end - start, nowPct = 34;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: "relative", display: "flex", height: 34, borderRadius: "var(--radius-sm)", overflow: "hidden", border: "1px solid var(--border-hairline)" }}>
|
||||
{items.map((it, i) => {
|
||||
const w = ((toMin(it.finish) - toMin(it.start)) / span) * 100;
|
||||
const filler = it.kind === "filler";
|
||||
return (
|
||||
<div key={i} title={it.title} style={{ width: `${w}%`, minWidth: 2, borderRight: i < items.length - 1 ? "1px solid var(--ctv-bg)" : "none", background: filler ? "var(--ctv-surface-3)" : "var(--ctv-accent-soft)", display: "flex", alignItems: "center", padding: "0 8px", overflow: "hidden" }}>
|
||||
{!filler && w > 8 && <span style={{ font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--ctv-accent)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.title.split(" — ")[0]}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ position: "absolute", top: -3, bottom: -3, left: `${nowPct}%`, width: 2, background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }}>
|
||||
<span style={{ position: "absolute", top: -4, left: -3, width: 8, height: 8, borderRadius: "50%", background: "var(--status-live)" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
|
||||
<span>{D.playoutItems[0].start.slice(0, 5)}</span>
|
||||
<span style={{ color: "var(--status-live)" }}>▲ now 20:31</span>
|
||||
<span>{D.playoutItems[D.playoutItems.length - 1].finish.slice(0, 5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const PLAYOUTS = [
|
||||
{ id: 1, num: "1.1", name: "Cartoon Junction", scheduleName: "Prime Time Cartoons", kind: "Classic", onAir: true, locked: false, now: "Tom & Jerry — The Cat Concerto" },
|
||||
{ id: 2, num: "2.1", name: "News 24", scheduleName: "World Tonight", kind: "Classic", onAir: true, locked: false, now: "World Tonight — Evening bulletin" },
|
||||
{ id: 3, num: "3.1", name: "Retro Toons Block", scheduleName: "Saturday AM Block", kind: "Block", onAir: false, locked: true, now: null },
|
||||
{ id: 4, num: "4.2", name: "Synthwave Radio", scheduleName: "80s Block", kind: "Sequential", onAir: true, locked: false, now: "80s Block — Synthpop hour" },
|
||||
{ id: 5, num: "6.1", name: "Kids Corner", scheduleName: "Sesame Loop", kind: "Classic", onAir: false, locked: false, now: null },
|
||||
{ id: 6, num: "7.1", name: "Docu Nights", scheduleName: "true-crime.yml", kind: "Scripted", onAir: false, locked: false, now: null },
|
||||
{ id: 7, num: "9.1", name: "PBS Kids Replay", scheduleName: "Sesame Street", kind: "Classic", onAir: true, locked: false, now: "Sesame Street — Episode 4102" },
|
||||
{ id: 8, num: "11.1", name: "Movie Vault", scheduleName: "dizque-export.json", kind: "ExternalJson", onAir: false, locked: false, now: null },
|
||||
];
|
||||
|
||||
function Playouts() {
|
||||
const all = D.channels;
|
||||
const [selNum, setSelNum] = React.useState("1.1");
|
||||
const [q, setQ] = React.useState("");
|
||||
const sel = D.channels.find((c) => c.num === selNum) || all[0];
|
||||
const now = D.playoutItems[2]; // Tom & Jerry
|
||||
const next = D.playoutItems[3];
|
||||
const NOW = {
|
||||
"1.1": "Tom & Jerry — The Cat Concerto", "2.1": "World Tonight", "4.2": "80s Block — Synthpop hour", "9.1": "Sesame Street",
|
||||
};
|
||||
const ql = q.trim().toLowerCase();
|
||||
const rail = all.filter((c) => c.enabled && (!ql || c.name.toLowerCase().includes(ql) || c.num.includes(ql)));
|
||||
const DETAIL = {
|
||||
mode: "Flood",
|
||||
scheduleName: "Prime Time Cartoons",
|
||||
kind: "Classic",
|
||||
rebuild: "04:00",
|
||||
seed: "88421193",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<Button variant="secondary" startIcon={<Ico n="RefreshCw" s={15} />}>Reset All</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Badge tone="warn" dot>3 warnings</Badge>
|
||||
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />} endIcon={<Ico n="ChevronDown" s={14} />}>Add Playout</Button>
|
||||
</div>
|
||||
const NEXT = { title: "Looney Tunes — Duck Amuck", start: "20:44", dur: "7m" };
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
{/* LEFT — searchable channel rail (scales to 128, no horizontal scroll) */}
|
||||
<div style={{ width: 248, flex: "0 0 auto", display: "flex", flexDirection: "column", borderRight: "1px solid var(--border-hairline)", minHeight: 0 }}>
|
||||
<div style={{ padding: "12px 12px 10px", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<Input size="sm" leadingIcon={<Ico n="Search" s={15} />} placeholder="Filter playouts…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 8 }}>
|
||||
{rail.map((c) => {
|
||||
const active = c.num === selNum;
|
||||
const nowT = NOW[c.num];
|
||||
return (
|
||||
<button key={c.num} type="button" onClick={() => setSelNum(c.num)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "8px 9px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
|
||||
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
|
||||
<ChannelLogo name={c.name} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-mono)", color: "var(--status-live)" }}>{c.num}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{c.live ? nowT || "On air" : "Idle"}
|
||||
</div>
|
||||
</div>
|
||||
{c.live && <StatusDot status="live" size={6} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
const ITEMS = [
|
||||
{ start: "19:58", finish: "20:31", title: "Tom & Jerry — The Cat Concerto", dur: "33m", filler: false },
|
||||
{ start: "20:31", finish: "20:34", title: "Bumper — Station ID", dur: "3m", filler: true },
|
||||
{ start: "20:34", finish: "20:44", title: "Tom & Jerry — Puss n' Toots", dur: "10m", filler: false },
|
||||
{ start: "20:44", finish: "20:51", title: "Looney Tunes — Duck Amuck", dur: "7m", filler: false },
|
||||
{ start: "20:51", finish: "20:53", title: "Bumper — Coming up next", dur: "2m", filler: true },
|
||||
{ start: "20:53", finish: "21:24", title: "Scooby-Doo — Mystery Mask Mix-Up", dur: "31m", filler: false },
|
||||
{ start: "21:24", finish: "21:55", title: "Looney Tunes — Rabbit Fire", dur: "31m", filler: false },
|
||||
];
|
||||
|
||||
{/* RIGHT — monitor for the selected playout */}
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: "auto" }}>
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* selected channel header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<ChannelLogo name={sel.name} size={38} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-sm)/1 var(--font-mono)", color: "var(--status-live)" }}>{sel.num}</span>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>{sel.name}</span>
|
||||
{sel.live && <Badge tone="accent" dot>On air</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* now / next hero */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* NOW */}
|
||||
<div style={{ display: "flex", alignItems: "stretch", background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
{/* preview frame */}
|
||||
<div style={{ position: "relative", width: 208, flex: "0 0 auto", background: "linear-gradient(135deg, #12151C, #0B0D11)", display: "flex", alignItems: "center", justifyContent: "center", borderRight: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 46, height: 46, borderRadius: "50%", background: "rgba(91,124,250,0.15)", border: "1px solid rgba(91,124,250,0.4)", color: "var(--ctv-accent)" }}><Ico n="Play" s={20} /></span>
|
||||
<span style={{ position: "absolute", top: 12, left: 12, display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 9px", borderRadius: "var(--radius-xs)", background: "rgba(15,17,21,0.6)", border: "1px solid var(--border-hairline)" }}>
|
||||
<StatusDot status="live" size={7} /><span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", color: "var(--text-primary)" }}>LIVE</span>
|
||||
</span>
|
||||
<span style={{ position: "absolute", bottom: 12, right: 12, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)", background: "rgba(15,17,21,0.6)", padding: "3px 7px", borderRadius: "var(--radius-xs)" }}>20:31:24</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, padding: 18, display: "flex", flexDirection: "column", justifyContent: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--status-live)" }}>On air now</span>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{sel.num} · {sel.name} · {sel.profile}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, font: "var(--weight-semibold) var(--text-lg)/1.25 var(--font-sans)", color: "var(--text-primary)" }}>{now.title}</div>
|
||||
<div style={{ marginTop: 14, height: 5, borderRadius: 999, background: "var(--ctv-surface-3)", overflow: "hidden" }}>
|
||||
<div style={{ width: "48%", height: "100%", background: "var(--status-live)" }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 7, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
|
||||
<span>{now.start} · started 11m ago</span>
|
||||
<span>13m to next</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* NEXT + PLAYOUT */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<Card title="Up next">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 42, height: 42, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", color: "var(--text-secondary)", flex: "0 0 auto" }}><Ico n="Film" s={18} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{next.title}</div>
|
||||
<div style={{ marginTop: 4, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>starts {next.start} · {next.dur}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Playout">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Row label="Source" value="Block Schedule" />
|
||||
<Row label="Schedule" value="Prime Time Cartoons" />
|
||||
<Row label="Rebuilds" value="daily · 04:00" />
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={13} />}>Reset</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Clock" s={13} />}>Schedule reset</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* timeline */}
|
||||
<Card title="Timeline" subtitle="Tonight's playout">
|
||||
<Timeline />
|
||||
</Card>
|
||||
|
||||
{/* upcoming */}
|
||||
<Card title="Upcoming" subtitle="Next items" padded={false}
|
||||
actions={<Switch checked size="sm" label="Show filler" onChange={() => {}} />}>
|
||||
<div>
|
||||
{D.playoutItems.slice(2, 7).map((it, i) => {
|
||||
const filler = it.kind === "filler";
|
||||
const isNow = i === 0;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none", background: isNow ? "var(--ctv-live-soft)" : "transparent" }}>
|
||||
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: isNow ? "var(--status-live)" : "var(--text-secondary)", width: 66, flex: "0 0 auto" }}>{it.start}</span>
|
||||
{isNow ? <StatusDot status="live" size={7} /> : <Ico n={filler ? "Sparkles" : "Film"} s={13} color={filler ? "var(--text-disabled)" : "var(--ctv-accent)"} />}
|
||||
<span style={{ flex: 1, font: "var(--text-sm)/1.3 var(--font-sans)", color: filler ? "var(--text-secondary)" : "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.title}</span>
|
||||
{filler && <Badge tone="neutral">Filler</Badge>}
|
||||
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-disabled)", flex: "0 0 auto" }}>{it.dur}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function nowProgress() {
|
||||
return 48;
|
||||
}
|
||||
|
||||
function Row({ label, value }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{value}</span>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--text-primary)" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Timeline() {
|
||||
const toMin = (t) => { const [h, m] = t.split(":").map(Number); return h * 60 + m; };
|
||||
const start = toMin(ITEMS[0].start), end = toMin(ITEMS[ITEMS.length - 1].finish);
|
||||
const span = end - start, nowPct = 46;
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<div style={{ position: "relative", display: "flex", height: 34, borderRadius: "var(--radius-sm)", overflow: "hidden", border: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
{ITEMS.map((it, i) => {
|
||||
const w = Math.max(((toMin(it.finish) - toMin(it.start)) / span) * 100, 2);
|
||||
return (
|
||||
<span key={i} title={it.title}
|
||||
style={{ display: "flex", alignItems: "center", overflow: "hidden", width: `${w}%`, borderRight: i < ITEMS.length - 1 ? "1px solid var(--ctv-bg)" : "none", background: it.filler ? "var(--ctv-surface-3)" : "var(--ctv-accent-soft)", color: it.filler ? "var(--text-secondary)" : "var(--ctv-accent)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", padding: "0 8px", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>
|
||||
{w > 12 ? it.title : ""}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span style={{ position: "absolute", top: -3, bottom: -3, left: `${nowPct}%`, width: 2, background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
|
||||
</div>
|
||||
<p style={{ display: "flex", justifyContent: "space-between", margin: 0, color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-mono)" }}>
|
||||
<span style={mono}>{ITEMS[0].start}</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, color: "var(--status-live)", fontFamily: "var(--font-sans)" }}><Ico n="Clock" s={12} /> now</span>
|
||||
<span style={mono}>{ITEMS[ITEMS.length - 1].finish}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Playouts() {
|
||||
const [filter, setFilter] = React.useState("");
|
||||
const [selId, setSelId] = React.useState(1);
|
||||
const [showFiller, setShowFiller] = React.useState(true);
|
||||
|
||||
const sel = PLAYOUTS.find((p) => p.id === selId) || PLAYOUTS[0];
|
||||
const q = filter.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? PLAYOUTS.filter((p) => p.name.toLowerCase().includes(q) || p.num.includes(q) || p.scheduleName.toLowerCase().includes(q))
|
||||
: PLAYOUTS;
|
||||
const warningsCount = 2;
|
||||
const visibleItems = showFiller ? ITEMS : ITEMS.filter((it) => !it.filler);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", gap: 12, padding: 20, minHeight: 0 }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)", padding: "12px 20px", flex: "0 0 auto" }}>
|
||||
<Button variant="secondary" startIcon={<Ico n="RefreshCw" s={15} />}>Reset all playouts</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Badge tone={warningsCount > 0 ? "warn" : "neutral"} dot={warningsCount > 0}>{warningsCount} warnings</Badge>
|
||||
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />}>Add Playout</Button>
|
||||
</div>
|
||||
|
||||
{/* grid */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "260px minmax(0, 1fr)", gap: 20, flex: 1, minHeight: 0 }}>
|
||||
{/* rail */}
|
||||
<aside style={{ display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)" }}>
|
||||
<div style={{ display: "grid", gap: 8, borderBottom: "1px solid var(--border-hairline)", padding: 12 }}>
|
||||
<Input size="sm" placeholder="Filter playouts..." value={filter} onChange={(e) => setFilter(e.target.value)} />
|
||||
<span style={{ color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)" }}>
|
||||
{filtered.length === PLAYOUTS.length ? `${PLAYOUTS.length} playouts loaded` : `${filtered.length} of ${PLAYOUTS.length} playouts`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", alignContent: "start", gap: 4, overflow: "auto", padding: 8 }}>
|
||||
{filtered.map((p) => {
|
||||
const active = p.id === sel.id;
|
||||
return (
|
||||
<button key={p.id} type="button" onClick={() => setSelId(p.id)}
|
||||
style={{ display: "grid", gridTemplateColumns: "30px minmax(0,1fr) 10px", alignItems: "center", gap: 10, width: "100%", border: "none", borderRadius: "var(--radius-sm)", background: active ? "var(--ctv-accent-soft)" : "transparent", color: "inherit", cursor: "pointer", padding: 8, textAlign: "left" }}>
|
||||
<ChannelLogo name={p.name} size={30} />
|
||||
<span style={{ display: "grid", minWidth: 0, gap: 4 }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<code style={{ ...mono, color: "var(--status-live)", fontSize: "var(--text-2xs)" }}>{p.num}</code>
|
||||
<strong style={{ overflow: "hidden", color: "var(--text-primary)", font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</strong>
|
||||
</span>
|
||||
<small style={{ overflow: "hidden", color: "var(--text-secondary)", font: "var(--text-2xs)/1.2 var(--font-sans)", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.now ?? p.scheduleName}</small>
|
||||
</span>
|
||||
{p.onAir && <StatusDot status="live" size={7} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* monitor */}
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 16, minHeight: 0, overflow: "auto", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)", padding: 20 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<ChannelLogo name={sel.name} size={38} />
|
||||
<div style={{ display: "grid", minWidth: 0, gap: 4 }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--text-secondary)", font: "var(--text-xs)/1 var(--font-sans)" }}>
|
||||
<code style={{ ...mono, fontSize: "var(--text-xs)" }}>{sel.num}</code>
|
||||
{sel.onAir && <Badge tone="accent" dot>On air</Badge>}
|
||||
{sel.locked && <Badge tone="warn" dot>Building…</Badge>}
|
||||
</span>
|
||||
<h2 style={{ margin: 0, color: "var(--text-primary)", font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)" }}>{sel.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* now playing */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "210px minmax(0,1fr)", overflow: "hidden", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)" }}>
|
||||
<div style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center", minHeight: 150, borderRight: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)", color: "var(--ctv-accent)" }}>
|
||||
<Ico n="Play" s={24} />
|
||||
<span style={{ position: "absolute", left: 10, bottom: 10 }}><Badge tone="neutral">Metadata preview only</Badge></span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: 10, minWidth: 0, padding: 20 }}>
|
||||
<span style={{ color: "var(--status-live)", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase" }}>On air now</span>
|
||||
<h3 style={{ margin: 0, color: "var(--text-primary)", font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)" }}>{sel.now ?? "No current item reported"}</h3>
|
||||
<ProgressBar value={sel.now ? nowProgress() : 0} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-mono)" }}>
|
||||
<code style={mono}>19:58</code>
|
||||
<code style={mono}>20:31</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* up next + playout detail */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
<Card title="Up next">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Ico n="Film" s={18} color="var(--ctv-accent)" />
|
||||
<span style={{ display: "grid", minWidth: 0, gap: 4 }}>
|
||||
<strong style={{ overflow: "hidden", color: "var(--text-primary)", font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{NEXT.title}</strong>
|
||||
<small style={{ color: "var(--text-secondary)", font: "var(--text-2xs)/1.2 var(--font-sans)" }}>{NEXT.start} · {NEXT.dur}</small>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Playout" actions={<Button size="sm" variant="ghost" startIcon={<Ico n="Pencil" s={13} />}>Edit details</Button>}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
<Input disabled label="Mode" value={DETAIL.mode} />
|
||||
<Input disabled label="Schedule" value={DETAIL.scheduleName} />
|
||||
<Input disabled label="Kind" value={DETAIL.kind} />
|
||||
<Input disabled label="Rebuild" value={DETAIL.rebuild} />
|
||||
<Input disabled label="Seed" value={DETAIL.seed} />
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--text-secondary)", font: "var(--text-xs)/1.4 var(--font-sans)" }}>
|
||||
The play order is saved and stays consistent across rebuilds. Reshuffle to roll a new order.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="CalendarClock" s={13} />}>Alternate schedules</Button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={13} />}>Reset</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Eraser" s={13} />}>Erase items and history</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Dices" s={13} />}>Reshuffle</Button>
|
||||
<Button size="sm" variant="danger" startIcon={<Ico n="Trash2" s={13} />}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Timeline" subtitle="Selected playout items">
|
||||
<Timeline />
|
||||
</Card>
|
||||
|
||||
<Card title="Upcoming" subtitle="Next items" padded={false}
|
||||
actions={<Switch checked={showFiller} onChange={setShowFiller} label="Show filler" size="sm" />}>
|
||||
<div role="list" aria-label="Upcoming playout items">
|
||||
{visibleItems.map((it, i) => (
|
||||
<div key={i} role="listitem"
|
||||
style={{ display: "grid", gridTemplateColumns: "58px 14px minmax(0,1fr) auto 64px", alignItems: "center", gap: 10, borderTop: i ? "1px solid var(--border-hairline)" : "none", padding: "10px 16px", background: i === 0 ? "var(--ctv-live-soft)" : "transparent" }}>
|
||||
<code style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{it.start}</code>
|
||||
<Ico n={it.filler ? "Sparkles" : "Film"} s={13} color="var(--ctv-accent)" />
|
||||
<span style={{ overflow: "hidden", color: "var(--text-primary)", font: "var(--text-sm)/1 var(--font-sans)", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.title}</span>
|
||||
{it.filler ? <Badge tone="neutral">Filler</Badge> : <span />}
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6, justifySelf: "end" }}>
|
||||
<small style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{it.dur}</small>
|
||||
<Tooltip label="Scheduling context">
|
||||
<IconButton size="sm" variant="ghost" title="Scheduling context"><Ico n="Info" s={13} /></IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Plex Source screen — a single media-source connection page: breadcrumb-style header, a
|
||||
// Connection card driving the OAuth pin-flow sign-in/out affordance, and a Servers list with
|
||||
// per-server refresh + edit-libraries/edit-path-replacements actions. Static mockup shows the
|
||||
// populated "signed in, servers discovered" state plus a toggled sign-out confirm overlay.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, StatusDot, Card, Spinner, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SERVERS = [
|
||||
{ id: 1, name: "Attic Media Server", address: "192.168.1.42:32400", status: "ok" },
|
||||
{ id: 2, name: "plex.tblindustries.be", address: "203.0.113.7:32400", status: "ok" },
|
||||
{ id: 3, name: "Backup NAS", address: "192.168.1.88:32400", status: "live" },
|
||||
];
|
||||
|
||||
function ConnectionRow({ label, value }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "9px 0", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-primary)" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerRow({ s }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const busy = s.status === "live";
|
||||
return (
|
||||
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 18px", borderTop: "1px solid var(--border-hairline)", background: hover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-3)", flex: "0 0 auto" }}>
|
||||
<Ico n="Server" s={16} color="var(--text-secondary)" />
|
||||
</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</div>
|
||||
<div style={{ marginTop: 2, ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.address}</div>
|
||||
</div>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<StatusDot status={busy ? "live" : "ok"} pulse={busy} size={7} />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: busy ? "var(--status-live)" : "var(--text-secondary)" }}>{busy ? "Busy" : "Ready"}</span>
|
||||
</span>
|
||||
<span style={{ width: 1, height: 22, background: "var(--border-hairline)" }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<Tooltip label="Refresh"><IconButton size="sm" title={"Refresh " + s.name} disabled={busy}><Ico n="RefreshCw" s={15} /></IconButton></Tooltip>
|
||||
<Button size="sm" variant="ghost">Edit Libraries</Button>
|
||||
<Button size="sm" variant="ghost">Edit Path Replacements</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutOverlay({ onCancel }) {
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 10 }}>
|
||||
<div style={{ width: 400, maxWidth: "90%", background: "var(--surface-raised)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-lg)", padding: 22 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 32, height: 32, borderRadius: "var(--radius-sm)", background: "var(--ctv-error-soft)" }}>
|
||||
<Ico n="TriangleAlert" s={16} color="var(--status-error)" />
|
||||
</span>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Sign out of Plex</span>
|
||||
</div>
|
||||
<p style={{ margin: "14px 0 0", font: "var(--text-sm)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Signing out removes all synced Plex servers, libraries, and content from ErsatzTV. This cannot be undone. Continue?
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 20 }}>
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="danger" startIcon={<Ico n="LogOut" s={14} />}>Sign out</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlexSource() {
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 28px" }}>
|
||||
{/* breadcrumb-style header (screen content, not app chrome) */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, marginBottom: 20 }}>
|
||||
<div>
|
||||
<span style={{ display: "block", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" }}>Media source</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6 }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)" }}>
|
||||
<Ico n="Server" s={16} color="var(--ctv-accent)" />
|
||||
</span>
|
||||
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-xl)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Plex</h2>
|
||||
</div>
|
||||
<p style={{ margin: "8px 0 0", font: "var(--text-sm)/1.5 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 520 }}>
|
||||
Sign in to Plex to discover servers and choose which libraries to sync.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />}>Back to libraries</Button>
|
||||
</div>
|
||||
|
||||
{/* Connection card */}
|
||||
<Card
|
||||
title={<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Connection</h3>}
|
||||
subtitle="Signed in to Plex."
|
||||
actions={
|
||||
<Button onClick={() => setConfirmOpen(true)} variant="secondary" startIcon={<Ico n="LogOut" s={14} />}>Sign out of Plex</Button>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<ConnectionRow label="Account" value="timothy@tblindustries.be" />
|
||||
<ConnectionRow label="Token expires" value="2027-01-04" />
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "9px 0" }}>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Servers discovered</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<StatusDot status="ok" size={7} />
|
||||
<span style={mono}>{SERVERS.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Servers card */}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Card
|
||||
title={<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Servers</h3>}
|
||||
subtitle={SERVERS.length + " servers discovered."}
|
||||
padded={false}
|
||||
>
|
||||
<div>
|
||||
{SERVERS.map((s) => <ServerRow key={s.id} s={s} />)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* transient sign-in state, for reference (kept visually collapsed/muted below the fold) */}
|
||||
<div style={{ marginTop: 16, display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-md)", background: "var(--ctv-accent-soft)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Spinner size={14} tone="accent" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Example in-flight state: <span style={{ color: "var(--text-primary)" }}>Waiting for you to approve access on plex.tv…</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmOpen && <SignOutOverlay onCancel={() => setConfirmOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVPlexSource = PlexSource;
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
// RemoteConnectionEdit screen — remote media source connection form with address & API key.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Card, Input } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
function RemoteConnectionEdit() {
|
||||
const [isDirty, setIsDirty] = React.useState(false);
|
||||
const [address, setAddress] = React.useState("http://192.168.1.50:8096");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const hasApiKey = true; // Simulate loaded state where key is already set
|
||||
const family = "jellyfin";
|
||||
const familyLabel = "Jellyfin";
|
||||
|
||||
const trimmedAddress = address.trim();
|
||||
const isValidAddress = trimmedAddress && (() => {
|
||||
try {
|
||||
const url = new URL(trimmedAddress);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const apiKeyValid = hasApiKey || apiKey.trim().length > 0;
|
||||
const isValid = isValidAddress && apiKeyValid;
|
||||
const showAddressError = trimmedAddress && !isValidAddress;
|
||||
|
||||
const handleAddressChange = (e) => {
|
||||
setAddress(e.target.value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (e) => {
|
||||
setApiKey(e.target.value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header section */}
|
||||
<section style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", padding: "24px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", textTransform: "uppercase", letterSpacing: "0.04em", color: "var(--text-secondary)", marginBottom: 8 }}>Media source</div>
|
||||
<h2 style={{ margin: "0 0 8px 0", font: "var(--weight-semibold) var(--text-2xl)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Edit {familyLabel} connection</h2>
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 600 }}>Point ErsatzTV at your {familyLabel} server and authenticate with an API key.</p>
|
||||
</div>
|
||||
<div style={{ flex: "0 0 auto", marginLeft: 20 }}>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />}>Back to {familyLabel}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 20px" }}>
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
{/* Connection Card */}
|
||||
<Card
|
||||
title={<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Connection</h3>}
|
||||
subtitle={`${familyLabel} server address and API key.`}
|
||||
padded
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Input
|
||||
label="Address"
|
||||
value={address}
|
||||
onChange={handleAddressChange}
|
||||
placeholder="http://192.168.1.50:8096"
|
||||
disabled={isSaving}
|
||||
error={showAddressError ? "Enter a valid absolute http(s) URL" : null}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="API key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
placeholder={hasApiKey ? "Key is set — leave blank to keep, or enter a new key" : "Enter an API key"}
|
||||
disabled={isSaving}
|
||||
error={!apiKeyValid && apiKey.length === 0 && !hasApiKey ? "API key is required" : null}
|
||||
fullWidth
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<div style={{ marginTop: 8, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", padding: "0 0 0 0" }}>
|
||||
Key is set — leave blank to keep the existing key, or enter a new key to replace it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, padding: "12px 20px", borderTop: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58, background: "var(--surface-app)" }}>
|
||||
<Button variant="ghost" disabled={!isDirty || isSaving} onClick={() => setIsDirty(false)}>Discard</Button>
|
||||
<Button variant="primary" disabled={!isValid || !isDirty || isSaving} loading={isSaving} startIcon={<Ico n="Check" s={14} />} onClick={() => setIsSaving(true)}>
|
||||
Save connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVRemoteConnectionEdit = RemoteConnectionEdit;
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
// RemoteLibrariesEdit screen — Plex/Jellyfin/Emby library sync preferences: a sortable
|
||||
// name/media-kind table with a per-row sync Switch, plus a save/discard toolbar.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Card, Switch, Badge } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const LIBRARIES = [
|
||||
{ id: 1, name: "Movies", mediaKind: "Movies", sync: true },
|
||||
{ id: 2, name: "TV Shows", mediaKind: "Shows", sync: true },
|
||||
{ id: 3, name: "Kids Movies", mediaKind: "Movies", sync: false },
|
||||
{ id: 4, name: "Anime", mediaKind: "Shows", sync: true },
|
||||
{ id: 5, name: "Home Videos", mediaKind: "OtherVideos", sync: false },
|
||||
{ id: 6, name: "Music Videos", mediaKind: "MusicVideos", sync: true },
|
||||
{ id: 7, name: "Live Feeds", mediaKind: "RemoteStreams", sync: false },
|
||||
{ id: 8, name: "Documentaries", mediaKind: "Movies", sync: true },
|
||||
];
|
||||
|
||||
function formatMediaKind(kind) {
|
||||
switch (kind) {
|
||||
case "MusicVideos":
|
||||
return "Music Videos";
|
||||
case "OtherVideos":
|
||||
return "Other Videos";
|
||||
case "RemoteStreams":
|
||||
return "Remote Streams";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
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: 52, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
|
||||
|
||||
function SortButton({ label, active, dir, onClick }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} style={{ display: "inline-flex", alignItems: "center", gap: 6, border: "none", background: "transparent", padding: 0, cursor: "pointer", font: `${active ? "var(--weight-medium)" : "inherit"} inherit`, color: active ? "var(--text-primary)" : "var(--text-disabled)" }}>
|
||||
{label}
|
||||
<Ico n="ArrowUpDown" s={13} color={active ? "var(--ctv-accent)" : "var(--text-faint)"} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteLibrariesEdit() {
|
||||
const [sortColumn, setSortColumn] = React.useState("name");
|
||||
const [sortDir, setSortDir] = React.useState("asc");
|
||||
const [draft, setDraft] = React.useState(LIBRARIES);
|
||||
const [saving] = React.useState(false);
|
||||
|
||||
const toggleSort = (col) => {
|
||||
if (sortColumn === col) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else {
|
||||
setSortColumn(col);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = [...draft].sort((a, b) => {
|
||||
const left = sortColumn === "name" ? a.name : a.mediaKind;
|
||||
const right = sortColumn === "name" ? b.name : b.mediaKind;
|
||||
const cmp = left.localeCompare(right);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const setSync = (id, next) => setDraft((cur) => cur.map((l) => (l.id === id ? { ...l, sync: next } : l)));
|
||||
|
||||
const dirty = draft.some((l) => {
|
||||
const orig = LIBRARIES.find((o) => o.id === l.id);
|
||||
return orig && orig.sync !== l.sync;
|
||||
});
|
||||
|
||||
const syncedCount = draft.filter((l) => l.sync).length;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, padding: "18px 20px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--ctv-accent)" }}>Plex libraries</div>
|
||||
<div style={{ marginTop: 6, font: "var(--weight-semibold) var(--text-xl)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Edit libraries</div>
|
||||
<div style={{ marginTop: 4, font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>Choose which Plex libraries ErsatzTV syncs.</div>
|
||||
</div>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />}>Back to Plex</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
{/* summary strip */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<Ico n="Library" s={13} color="var(--text-disabled)" />
|
||||
<span style={mono}>{draft.length}</span> libraries discovered
|
||||
</span>
|
||||
<span style={{ width: 1, height: 14, background: "var(--border-hairline)" }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--status-ok)" }} />
|
||||
<span style={mono}>{syncedCount}</span> syncing
|
||||
</span>
|
||||
{dirty && <Badge tone="warn">Unsaved changes</Badge>}
|
||||
</div>
|
||||
|
||||
<Card padded={false}>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}><SortButton label="Name" active={sortColumn === "name"} onClick={() => toggleSort("name")} /></th>
|
||||
<th style={th}><SortButton label="Media Kind" active={sortColumn === "mediaKind"} onClick={() => toggleSort("mediaKind")} /></th>
|
||||
<th style={{ ...th, width: 160 }}>Sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((library, i) => (
|
||||
<tr key={library.id} style={{ borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<td style={td}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 9 }}>
|
||||
<Ico n="Folder" s={14} color="var(--text-disabled)" />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)" }}>{library.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...td, color: "var(--text-secondary)" }}>{formatMediaKind(library.mediaKind)}</td>
|
||||
<td style={td}>
|
||||
<Switch checked={library.sync} disabled={saving} onChange={(next) => setSync(library.id, next)} label={`Sync ${library.name}`} size="sm" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 20px", borderTop: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button disabled={!dirty} variant="ghost">Discard</Button>
|
||||
<Button disabled={!dirty} startIcon={<Ico n="Check" s={14} />}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVRemoteLibrariesEdit = RemoteLibrariesEdit;
|
||||
})();
|
||||
@@ -0,0 +1,127 @@
|
||||
// RemoteSource screen — shared Jellyfin/Emby connection screen: a connection
|
||||
// card (connect / edit / disconnect) plus a server list with per-server
|
||||
// refresh + library/path-replacement shortcuts. Mirrors RemoteSourceScreen.tsx.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, StatusDot, Card, Spinner, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SERVERS = [
|
||||
{ id: 1, name: "Living Room Jellyfin", address: "https://jellyfin.tblindustries.be:8920", locked: false, libraries: 6 },
|
||||
{ id: 2, name: "Basement NAS", address: "http://192.168.1.44:8096", locked: true, libraries: 3 },
|
||||
];
|
||||
|
||||
function Kicker({ children }) {
|
||||
return <span style={{ display: "block", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)", marginBottom: 6 }}>{children}</span>;
|
||||
}
|
||||
|
||||
function ServerRow({ server }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px var(--pad-cell-x)", borderTop: "1px solid var(--border-hairline)", background: hover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-3)", flex: "0 0 auto" }}>
|
||||
<Ico n="Server" s={16} color="var(--text-secondary)" />
|
||||
</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{server.name}</span>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{server.libraries} libraries</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-faint)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{server.address}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, flex: "0 0 auto" }}>
|
||||
<StatusDot status={server.locked ? "live" : "ok"} label={server.locked ? "Busy" : "Ready"} size={7} />
|
||||
<Tooltip label={server.locked ? "Scan in progress" : "Refresh libraries"}>
|
||||
<IconButton size="sm" title={`Refresh ${server.name}`} disabled={server.locked}>
|
||||
{server.locked ? <Spinner size={14} tone="muted" /> : <Ico n="RefreshCw" s={15} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button size="sm" variant="ghost">Edit Libraries</Button>
|
||||
<Button size="sm" variant="ghost">Edit Path Replacements</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteSource() {
|
||||
const [connected, setConnected] = React.useState(true);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, padding: "16px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div>
|
||||
<Kicker>Media source</Kicker>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)" }}>
|
||||
<Ico n="Server" s={16} color="var(--ctv-accent)" />
|
||||
</span>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-xl)/1.1 var(--font-sans)", color: "var(--text-primary)" }}>Jellyfin</span>
|
||||
</div>
|
||||
<p style={{ margin: "8px 0 0", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 480 }}>
|
||||
Connect a Jellyfin server and choose which libraries to sync.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
{/* demo-only toggle to preview connected / empty states, not part of the live screen */}
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: true, l: "Connected" }, { v: false, l: "Not connected" }].map((o) => {
|
||||
const on = o.v === connected;
|
||||
return (
|
||||
<button key={o.l} type="button" onClick={() => setConnected(o.v)}
|
||||
style={{ height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />}>Back to libraries</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card
|
||||
title={<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)" }}>Connection</h3>}
|
||||
subtitle={connected ? "Connected to Jellyfin." : "No Jellyfin server connected."}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button startIcon={<Ico n="Plug" s={14} />} variant={connected ? "secondary" : "primary"}>
|
||||
{connected ? "Edit connection" : "Connect"}
|
||||
</Button>
|
||||
{connected && (
|
||||
<Button startIcon={<Ico n="Unplug" s={14} />} variant="secondary">Disconnect</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!connected && (
|
||||
<div style={{ padding: "18px 4px", display: "flex", alignItems: "center", gap: 10, color: "var(--text-faint)", font: "var(--text-sm)/1.4 var(--font-sans)" }}>
|
||||
<Ico n="PlugZap" s={16} color="var(--text-disabled)" />
|
||||
Connect a Jellyfin server to sync libraries.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{connected && (
|
||||
<Card
|
||||
title={<h3 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)" }}>Servers</h3>}
|
||||
subtitle={`${SERVERS.length} servers.`}
|
||||
padded={false}
|
||||
>
|
||||
<div role="list" aria-label="Jellyfin servers">
|
||||
{SERVERS.map((s) => <ServerRow key={s.id} server={s} />)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVRemoteSource = RemoteSource;
|
||||
})();
|
||||
@@ -0,0 +1,200 @@
|
||||
// Rerun Collections screen — a flat, flush-row list of named rerun collections
|
||||
// (source type + selection at a glance) with a full-bleed slide-in editor for
|
||||
// add/edit: name, collection type, selection picker, first-run/rerun order.
|
||||
(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 COLLECTION_TYPES = [
|
||||
{ label: "Collection", value: "Collection" },
|
||||
{ label: "Multi Collection", value: "MultiCollection" },
|
||||
{ label: "Smart Collection", value: "SmartCollection" },
|
||||
{ label: "Television Show", value: "TelevisionShow" },
|
||||
{ label: "Television Season", value: "TelevisionSeason" },
|
||||
{ label: "Artist", value: "Artist" },
|
||||
{ label: "Movie", value: "Movie" },
|
||||
{ label: "Episode", value: "Episode" },
|
||||
{ label: "Music Video", value: "MusicVideo" },
|
||||
{ label: "Other Video", value: "OtherVideo" },
|
||||
{ label: "Song", value: "Song" },
|
||||
{ label: "Image", value: "Image" },
|
||||
{ label: "Remote Stream", value: "RemoteStream" },
|
||||
];
|
||||
|
||||
const ORDER_OPTIONS = [
|
||||
{ label: "Chronological", value: "Chronological" },
|
||||
{ label: "Season, Episode", value: "SeasonEpisode" },
|
||||
{ label: "Random", value: "Random" },
|
||||
{ label: "Shuffle", value: "Shuffle" },
|
||||
];
|
||||
|
||||
const PICKER_OPTIONS = [
|
||||
{ label: "Select…", value: "" },
|
||||
{ label: "Classic Sitcoms", value: "1" },
|
||||
{ label: "Late Night Movies", value: "2" },
|
||||
{ label: "Saturday Morning Cartoons", value: "3" },
|
||||
{ label: "80s Music Videos", value: "4" },
|
||||
];
|
||||
|
||||
const RERUNS = [
|
||||
{ id: 1, name: "Sitcom Reruns — Prime", type: "Collection", selection: "Classic Sitcoms" },
|
||||
{ id: 2, name: "Late Night Block", type: "MultiCollection", selection: "Late Night Movies + Talk" },
|
||||
{ id: 3, name: "Saturday Morning", type: "TelevisionShow", selection: "Looney Tunes" },
|
||||
{ id: 4, name: "Weekday Filler", type: "SmartCollection", selection: "Unwatched < 30d" },
|
||||
{ id: 5, name: "Overnight Music Videos", type: "MusicVideo", selection: "80s Music Videos" },
|
||||
{ id: 6, name: "Rainy Day Movies", type: "Movie", selection: "Family Matinee" },
|
||||
{ id: 7, name: "News Wallpaper", type: "OtherVideo", selection: "Local Access Filler" },
|
||||
];
|
||||
|
||||
const typeLabel = (v) => (COLLECTION_TYPES.find((t) => t.value === v) || {}).label || v;
|
||||
|
||||
const actionbar = { display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 };
|
||||
const row = { display: "flex", alignItems: "center", gap: 12, padding: "0 var(--pad-cell-x)", height: 52, borderTop: "1px solid var(--border-hairline)" };
|
||||
|
||||
function RerunRow({ item, first, onEdit }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{ ...row, borderTop: first ? "none" : row.borderTop, background: hover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
|
||||
>
|
||||
<Ico n="Repeat" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
style={{ flex: 1, minWidth: 0, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
<Badge tone="neutral">{typeLabel(item.type)} · {item.selection}</Badge>
|
||||
<IconButton size="sm" title="Delete" variant="ghost">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RerunCollectionEditor({ initial, onBack }) {
|
||||
const isEdit = !!initial;
|
||||
const [name, setName] = React.useState(initial ? initial.name : "");
|
||||
const [collectionType, setCollectionType] = React.useState(initial ? initial.type : "Collection");
|
||||
const [selected, setSelected] = React.useState(initial ? "1" : "");
|
||||
const [firstRunOrder, setFirstRunOrder] = React.useState("Chronological");
|
||||
const [rerunOrder, setRerunOrder] = React.useState("Chronological");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={actionbar}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>
|
||||
All rerun collections
|
||||
</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{isEdit ? "Edit rerun collection" : "New rerun collection"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>
|
||||
{isEdit ? "Save rerun collection" : "Add rerun collection"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<div style={{ maxWidth: 520 }}>
|
||||
<Card title="Rerun collection">
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Rerun collection name" />
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Select
|
||||
label="Collection Type"
|
||||
value={collectionType}
|
||||
onChange={(e) => setCollectionType(e.target.value)}
|
||||
options={COLLECTION_TYPES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
label={typeLabel(collectionType)}
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
options={PICKER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
label="First Run Playback Order"
|
||||
value={firstRunOrder}
|
||||
onChange={(e) => setFirstRunOrder(e.target.value)}
|
||||
options={ORDER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
label="Rerun Playback Order"
|
||||
value={rerunOrder}
|
||||
onChange={(e) => setRerunOrder(e.target.value)}
|
||||
options={ORDER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RerunCollections() {
|
||||
const [editing, setEditing] = React.useState(null); // null | { kind: "new" } | { kind: "edit", item }
|
||||
const [loading] = React.useState(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<RerunCollectionEditor
|
||||
initial={editing.kind === "edit" ? editing.item : null}
|
||||
onBack={() => setEditing(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 10, color: "var(--text-secondary)", font: "var(--text-sm)/1 var(--font-sans)" }}>
|
||||
<Spinner size={18} />
|
||||
<span>Loading rerun collections…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={actionbar}>
|
||||
<Badge tone="neutral">{RERUNS.length} rerun collections</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={() => setEditing({ kind: "new" })}>
|
||||
New rerun collection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{RERUNS.length === 0 ? (
|
||||
<div style={{ padding: "32px 20px", textAlign: "center", color: "var(--text-faint)", font: "var(--text-sm)/1 var(--font-sans)" }}>
|
||||
No rerun collections yet.
|
||||
</div>
|
||||
) : (
|
||||
RERUNS.map((item, i) => (
|
||||
<RerunRow key={item.id} item={item} first={i === 0} onEdit={() => setEditing({ kind: "edit", item })} />
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVRerunCollections = RerunCollections;
|
||||
})();
|
||||
@@ -1,245 +0,0 @@
|
||||
// Schedule editor — GROUND-UP redesign as a visual "lineup builder".
|
||||
// Real ErsatzTV fields/vocabulary, but a block-timeline composition model with a
|
||||
// tabbed inspector — replacing the inherited table + 25-field stacked form.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Card, Button, IconButton, Badge, Select, Input, Switch, Checkbox, Tabs, ChannelLogo, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const COLL_TYPES = ["Collection", "Television Show", "Season", "Artist", "Multi Collection", "Smart Collection", "Search Query", "Playlist", "Rerun (First Run)", "Rerun (Rerun)"];
|
||||
const ORDERS = ["Chronological", "Random", "Shuffle", "Shuffle In Order", "Marathon", "Season/Episode", "Multi-Episode Shuffle"];
|
||||
const MODES = ["Flood", "One", "Multiple", "Duration"];
|
||||
const ORDER_ICON = { "Chronological": "ArrowDownWideNarrow", "Random": "Dices", "Shuffle": "Shuffle", "Shuffle In Order": "ListOrdered", "Marathon": "Flame", "Season/Episode": "ListTree", "Multi-Episode Shuffle": "Shuffle" };
|
||||
|
||||
// Fill-mode visual descriptor
|
||||
function fillOf(it) {
|
||||
if (it.mode === "Flood") return { icon: "MoveVertical", label: "Fills to next", grow: true };
|
||||
if (it.mode === "One") return { icon: "Square", label: "1 item" };
|
||||
if (it.mode === "Multiple") return { icon: "Copy", label: "×" + it.count };
|
||||
if (it.mode === "Duration") return { icon: "Timer", label: it.durEst.replace("~", "") };
|
||||
return { icon: "Square", label: it.mode };
|
||||
}
|
||||
|
||||
function Block({ it, active, onClick, dragging, isOver, dragProps }) {
|
||||
const fill = fillOf(it);
|
||||
const filler = it.guide === "Filler";
|
||||
return (
|
||||
<div onClick={onClick} {...dragProps}
|
||||
style={{
|
||||
position: "relative", display: "flex", flexDirection: "column", gap: 8,
|
||||
padding: "12px 13px", borderRadius: "var(--radius-md)", cursor: "pointer",
|
||||
background: active ? "var(--ctv-accent-soft)" : "var(--surface-card)",
|
||||
border: `1px solid ${active ? "rgba(91,124,250,0.45)" : "var(--border-hairline)"}`,
|
||||
boxShadow: isOver ? "0 -2px 0 var(--ctv-accent)" : "none",
|
||||
opacity: dragging ? 0.4 : 1,
|
||||
transition: "background var(--dur-fast), border-color var(--dur-fast)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
|
||||
<span style={{ display: "inline-flex", cursor: "grab", color: "var(--text-faint)", flex: "0 0 auto" }} title="Drag to reorder"><Ico n="GripVertical" s={16} /></span>
|
||||
<span style={{ position: "relative", flex: "0 0 auto" }}>
|
||||
<ChannelLogo name={it.collection} size={38} radius="var(--radius-sm)" />
|
||||
{filler && <span style={{ position: "absolute", inset: 0, borderRadius: "var(--radius-sm)", background: "rgba(15,17,21,0.45)" }} />}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.collection}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{it.collType}</div>
|
||||
</div>
|
||||
{/* fill-mode visual */}
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: "0 0 auto", padding: "5px 9px", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n={fill.icon} s={13} color="var(--text-secondary)" />
|
||||
<span style={{ ...(fill.label.startsWith("×") || /\d/.test(fill.label) ? mono : {}), font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{fill.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* meta chips */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", paddingLeft: 27 }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n={ORDER_ICON[it.order] || "Shuffle"} s={13} color="var(--text-disabled)" />{it.order}
|
||||
</span>
|
||||
{filler ? <Badge tone="neutral">Hidden from guide</Badge> : null}
|
||||
{(it.pre || it.mid || it.post) && (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<Ico n="Sparkles" s={12} />
|
||||
{[it.pre && "pre", it.mid && "mid", it.post && "post"].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{it.durEst}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inspector({ sel, idx }) {
|
||||
const [tab, setTab] = React.useState("content");
|
||||
const fill = fillOf(sel);
|
||||
return (
|
||||
<div style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", display: "flex", flexDirection: "column", minHeight: 0, height: "100%" }}>
|
||||
{/* summary header */}
|
||||
<div style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ChannelLogo name={sel.collection} size={34} radius="var(--radius-sm)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{sel.collection}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Item {idx + 1} · {sel.collType}</div>
|
||||
</div>
|
||||
<IconButton title="Remove item"><Ico n="Trash2" s={15} /></IconButton>
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<Badge tone={sel.startType === "Fixed" ? "accent" : "neutral"} dot>{sel.startType === "Fixed" ? sel.start : "Dynamic"}</Badge>
|
||||
<Badge tone="neutral">{sel.order}</Badge>
|
||||
<Badge tone="neutral">{fill.label === "1 item" ? "One" : sel.mode}{sel.mode === "Multiple" ? " " + fill.label : ""}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<Tabs value={tab} onChange={setTab} tabs={[
|
||||
{ value: "content", label: "Content" },
|
||||
{ value: "playback", label: "Playback" },
|
||||
{ value: "filler", label: "Filler" },
|
||||
{ value: "overrides", label: "Overrides" },
|
||||
]} />
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{tab === "content" && (
|
||||
<React.Fragment>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Select label="Start type" value={sel.startType} onChange={() => {}} options={["Dynamic", "Fixed"]} />
|
||||
<Input label="Start time" value={sel.startType === "Fixed" ? sel.start : ""} placeholder="dynamic" onChange={() => {}} />
|
||||
</div>
|
||||
<Select label="Collection type" value={sel.collType} onChange={() => {}} options={COLL_TYPES} />
|
||||
<Input label="Collection" value={sel.collection} onChange={() => {}} />
|
||||
<Input label="Custom title" placeholder="Optional — shown in the guide" onChange={() => {}} />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, alignItems: "center" }}>
|
||||
<Select label="Guide mode" value={sel.guide} onChange={() => {}} options={["Normal", "Filler"]} />
|
||||
<div style={{ paddingTop: 18 }}><Checkbox checked label="Keep multi-part together" onChange={() => {}} /></div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{tab === "playback" && (
|
||||
<React.Fragment>
|
||||
<Select label="Playback order" value={sel.order} onChange={() => {}} options={ORDERS} />
|
||||
<Select label="Playout mode" value={sel.mode} onChange={() => {}} options={MODES} />
|
||||
{sel.mode === "Multiple" && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Select label="Multiple mode" value="Count" onChange={() => {}} options={["Count", "Collection Size", "Multi-Episode Group Size"]} />
|
||||
<Input label="Multiple count" value={String(sel.count)} onChange={() => {}} />
|
||||
</div>
|
||||
)}
|
||||
{sel.mode === "Duration" && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Input label="Hours" value="1" onChange={() => {}} />
|
||||
<Input label="Minutes" value="30" onChange={() => {}} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: "12px 13px", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Ico n={fill.icon} s={16} color="var(--ctv-accent)" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{sel.mode === "Flood" ? "Plays until the next fixed-start item." : sel.mode === "One" ? "Plays exactly one item, then advances." : sel.mode === "Multiple" ? `Plays ${sel.count} items, then advances.` : "Plays for a fixed duration."}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{tab === "filler" && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Select label="Pre-roll" value={sel.pre ? "Retro Bumpers" : "None"} onChange={() => {}} options={["None", "Retro Bumpers", "Station IDs"]} />
|
||||
<Select label="Mid-roll" value={sel.mid ? "Ad Break" : "None"} onChange={() => {}} options={["None", "Ad Break", "Promos"]} />
|
||||
<Select label="Post-roll" value={sel.post ? "Outro" : "None"} onChange={() => {}} options={["None", "Outro"]} />
|
||||
<Select label="Tail" value="None" onChange={() => {}} options={["None", "Test Pattern"]} />
|
||||
<Select label="Fallback" value="Test Pattern" onChange={() => {}} options={["None", "Test Pattern", "Color Bars"]} />
|
||||
</div>
|
||||
)}
|
||||
{tab === "overrides" && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Select label="Watermark" value="Channel logo" onChange={() => {}} options={["Inherit", "Channel logo", "None"]} />
|
||||
<Select label="Subtitle mode" value="Any" onChange={() => {}} options={["None", "Any", "Forced", "Default"]} />
|
||||
<Select label="Preferred audio" value="English" onChange={() => {}} options={["English", "Spanish", "French"]} />
|
||||
<Select label="Preferred subtitle" value="None" onChange={() => {}} options={["None", "English", "Spanish"]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditor() {
|
||||
const [items, setItems] = React.useState(D.scheduleItems);
|
||||
const [selId, setSelId] = React.useState(D.scheduleItems[0].id);
|
||||
const [dragId, setDragId] = React.useState(null);
|
||||
const [overId, setOverId] = React.useState(null);
|
||||
const sel = items.find((i) => i.id === selId) || items[0];
|
||||
const selIdx = items.findIndex((i) => i.id === sel.id);
|
||||
|
||||
const onDrop = (targetId) => {
|
||||
if (dragId == null || dragId === targetId) { setDragId(null); setOverId(null); return; }
|
||||
const from = items.findIndex((i) => i.id === dragId);
|
||||
const to = items.findIndex((i) => i.id === targetId);
|
||||
const next = items.slice();
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
setItems(next); setDragId(null); setOverId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)" }}><Ico n="CalendarClock" s={18} /></span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Prime Time Cartoons</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Block schedule · programs <span style={mono}>~3h 46m</span> · loops to fill the block</div>
|
||||
</div>
|
||||
<Button variant="secondary" startIcon={<Ico n="Play" s={15} />}>Preview playout</Button>
|
||||
<Button variant="primary" startIcon={<Ico n="Check" s={15} />}>Save</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "minmax(420px, 1fr) minmax(360px, 460px)", gap: 18, padding: 20 }}>
|
||||
{/* LEFT lineup on a time rail */}
|
||||
<div style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" }}>Lineup · drag to reorder</span>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", paddingRight: 4 }}>
|
||||
{items.map((it, idx) => {
|
||||
const isFixed = it.startType === "Fixed";
|
||||
return (
|
||||
<div key={it.id} style={{ display: "flex", gap: 12 }}>
|
||||
{/* time rail */}
|
||||
<div style={{ width: 52, flex: "0 0 auto", display: "flex", flexDirection: "column", alignItems: "center", position: "relative" }}>
|
||||
{idx > 0 && <span style={{ position: "absolute", top: 0, height: 16, width: 2, background: "var(--border-hairline)" }} />}
|
||||
<div style={{ marginTop: isFixed ? 14 : 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "50%", background: isFixed ? "var(--ctv-accent)" : "transparent", border: `2px solid ${isFixed ? "var(--ctv-accent)" : "var(--border-control)"}` }} />
|
||||
{isFixed
|
||||
? <span style={{ ...mono, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-mono)", color: "var(--ctv-accent)" }}>{it.start}</span>
|
||||
: <span style={{ font: "9px/1 var(--font-sans)", color: "var(--text-faint)" }}>flows</span>}
|
||||
</div>
|
||||
{idx < items.length - 1 && <span style={{ position: "absolute", bottom: -12, top: 40, width: 2, background: "var(--border-hairline)" }} />}
|
||||
</div>
|
||||
{/* block */}
|
||||
<div style={{ flex: 1, minWidth: 0, paddingBottom: 12 }}>
|
||||
<Block it={it} active={it.id === selId} onClick={() => setSelId(it.id)}
|
||||
dragging={it.id === dragId} isOver={it.id === overId && dragId != null && dragId !== it.id}
|
||||
dragProps={{
|
||||
draggable: true,
|
||||
onDragStart: () => setDragId(it.id),
|
||||
onDragEnd: () => { setDragId(null); setOverId(null); },
|
||||
onDragOver: (e) => { e.preventDefault(); setOverId(it.id); },
|
||||
onDrop: () => onDrop(it.id),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT inspector */}
|
||||
<Inspector sel={sel} idx={selIdx} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
window.CTVScheduleEditor = ScheduleEditor;
|
||||
})();
|
||||
@@ -1,117 +0,0 @@
|
||||
// Libraries screen — GROUND-UP: media sources as connection cards (Local / Plex /
|
||||
// Jellyfin / Emby), each listing its libraries with live scan state — a modern
|
||||
// "connections" surface, not a flat table.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, StatusDot, ProgressBar, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const KIND = {
|
||||
Local: { icon: "HardDrive", tint: "var(--ctv-accent)" },
|
||||
Plex: { icon: "Server", tint: "#E5A00D" },
|
||||
Jellyfin: { icon: "Server", tint: "#8A5CF6" },
|
||||
Emby: { icon: "Server", tint: "#52B54B" },
|
||||
};
|
||||
const LIB_STATUS = {
|
||||
ok: { tone: "ok", label: "Synced" },
|
||||
warn: { tone: "warn", label: "Needs attention" },
|
||||
scan: { tone: "accent", label: "Scanning" },
|
||||
};
|
||||
const MEDIA_ICON = { "Movies": "Clapperboard", "Shows": "MonitorPlay", "Other Videos": "Video", "Music Videos": "Music" };
|
||||
|
||||
function SourceCard({ src }) {
|
||||
const k = KIND[src.kind] || KIND.Local;
|
||||
const totalItems = src.libs.reduce((a, l) => a + l.items, 0);
|
||||
const scanning = src.libs.some((l) => l.status === "scan");
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
{/* source header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 11, padding: "13px 15px", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-3)", color: k.tint, flex: "0 0 auto" }}><Ico n={k.icon} s={18} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src.name}</span>
|
||||
<StatusDot status={src.status === "warn" ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div style={{ marginTop: 4, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{src.detail}</div>
|
||||
</div>
|
||||
<Tooltip label="Scan source"><IconButton size="sm" title="Scan"><Ico n="RefreshCw" s={15} /></IconButton></Tooltip>
|
||||
<Tooltip label="Settings"><IconButton size="sm" title="Settings"><Ico n="Settings2" s={15} /></IconButton></Tooltip>
|
||||
</div>
|
||||
{/* libraries */}
|
||||
<div style={{ flex: 1 }}>
|
||||
{src.libs.map((lib, i) => {
|
||||
const s = LIB_STATUS[lib.status];
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 11, padding: "11px 15px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", color: "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={MEDIA_ICON[lib.media] || "Folder"} s={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{lib.name}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{lib.media} · <span style={mono}>{lib.items.toLocaleString()}</span> items</div>
|
||||
</div>
|
||||
{lib.status === "scan"
|
||||
? <div style={{ width: 118 }}><ProgressBar value={lib.pct} showLabel /></div>
|
||||
: <Badge tone={s.tone} dot>{s.label}</Badge>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* footer */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "9px 15px", borderTop: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<Ico n="Clock" s={13} color="var(--text-faint)" />
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
{scanning ? "Scanning now" : `Last scan ${src.lastScan}`}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}><span style={mono}>{totalItems.toLocaleString()}</span> items · {src.libs.length} librar{src.libs.length === 1 ? "y" : "ies"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSourceCard() {
|
||||
const opts = [["HardDrive", "Local folder"], ["Server", "Plex"], ["Server", "Jellyfin"], ["Server", "Emby"]];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16, background: "var(--surface-card)", border: "1px dashed var(--border-control)", borderRadius: "var(--radius-md)", alignItems: "flex-start", justifyContent: "center" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)" }}><Ico n="Plus" s={18} /></span>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Connect a media source</div>
|
||||
<div style={{ marginTop: 4, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>Point ChicoryTV at a local folder or a Plex / Jellyfin / Emby server.</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
||||
{opts.map(([ic, label], i) => (
|
||||
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n={ic} s={13} />{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Libraries() {
|
||||
const totalItems = D.sources.reduce((a, s) => a + s.libs.reduce((b, l) => b + l.items, 0), 0);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<Button variant="secondary" startIcon={<Ico n="RefreshCw" s={15} />}>Scan All</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={mono}>{D.sources.length}</span> sources · <span style={mono}>{totalItems.toLocaleString()}</span> items
|
||||
</span>
|
||||
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />}>Add Source</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(360px, 1fr))", gap: 16, alignItems: "start" }}>
|
||||
{D.sources.map((s, i) => <SourceCard key={i} src={s} />)}
|
||||
<AddSourceCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVLibraries = Libraries;
|
||||
})();
|
||||
@@ -0,0 +1,356 @@
|
||||
// Schedules screen — a schedule picker header, a lineup/inspector split (draggable-looking
|
||||
// timeline blocks on the left, a deep property inspector on the right) for building a channel's
|
||||
// programming lineup item by item.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Select, Input, Checkbox, Tag, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const humanize = (s) => (s || "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
// ---- mock data ----------------------------------------------------------
|
||||
|
||||
const SCHEDULES = [
|
||||
{ id: 1, name: "Primetime Rotation", shuffle: false },
|
||||
{ id: 2, name: "Weekday Mornings", shuffle: false },
|
||||
{ id: 3, name: "Movie Marathon Weekend", shuffle: true },
|
||||
{ id: 4, name: "News Loop", shuffle: false },
|
||||
{ id: 5, name: "Holiday Marathon 2026", shuffle: false },
|
||||
];
|
||||
|
||||
const mk = (overrides) => ({
|
||||
startType: "Fixed",
|
||||
startTime: "18:00:00",
|
||||
playoutMode: "Duration",
|
||||
playbackOrder: "Chronological",
|
||||
guideMode: "Normal",
|
||||
durationH: 1,
|
||||
durationM: 0,
|
||||
tailMode: "None",
|
||||
discardAttempts: 4,
|
||||
filler: { pre: "(none)", mid: "(none)", post: "(none)", tail: "(none)", fallback: "(none)" },
|
||||
customTitle: "",
|
||||
watermarks: [],
|
||||
graphics: [],
|
||||
audioLang: "(none)",
|
||||
audioTitle: "",
|
||||
subtitleLang: "(none)",
|
||||
subtitleMode: "Inherit",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const ITEMS_BY_SCHEDULE = {
|
||||
1: [
|
||||
mk({ _key: "a1", collectionType: "Collection", title: "80s Sitcoms Collection", startTime: "18:00:00", playoutMode: "Duration", playbackOrder: "Shuffle", durationH: 1, durationM: 30, tailMode: "Filler", discardAttempts: 6, filler: { pre: "(none)", mid: "Ad Break Bumpers", post: "(none)", tail: "(none)", fallback: "Technical Difficulties" }, watermarks: ["Corner Bug"], graphics: ["Lower Third — Now Playing"], audioLang: "English" }),
|
||||
mk({ _key: "a2", collectionType: "MultiCollection", title: "Sketch Comedy Mix", startType: "Flexible", startTime: null, playoutMode: "Flood", playbackOrder: "Chronological" }),
|
||||
mk({ _key: "a3", collectionType: "RerunFirstRun", title: "Prime Drama Rerun Pool", startTime: "20:00:00", playoutMode: "One", playbackOrder: "Chronological" }),
|
||||
mk({ _key: "a4", collectionType: "SmartCollection", title: "Recently Added — Action", startType: "Flexible", startTime: null, playoutMode: "Multiple", playbackOrder: "Shuffle", guideMode: "Filler" }),
|
||||
mk({ _key: "a5", collectionType: "TelevisionShow", title: "The Wire", startTime: "22:00:00", playoutMode: "Duration", playbackOrder: "Chronological", durationH: 1, durationM: 0 }),
|
||||
mk({ _key: "a6", collectionType: "Playlist", title: "Late Night Bumper Playlist", startType: "Flexible", startTime: null, playoutMode: "One", playbackOrder: "Chronological", guideMode: "Filler" }),
|
||||
mk({ _key: "a7", collectionType: "Artist", title: "Talking Heads — Music Video Block", startTime: "23:30:00", playoutMode: "Marathon", playbackOrder: "Shuffle" }),
|
||||
mk({ _key: "a8", collectionType: "SearchQuery", title: 'Search: "Christmas" (1990–2005)', startType: "Flexible", startTime: null, playoutMode: "Flood", playbackOrder: "Chronological" }),
|
||||
],
|
||||
2: [
|
||||
mk({ _key: "b1", collectionType: "Collection", title: "Morning Cartoons Block", startTime: "06:00:00", playoutMode: "Duration", playbackOrder: "Shuffle", durationH: 2, durationM: 0 }),
|
||||
mk({ _key: "b2", collectionType: "SmartCollection", title: "Weather + Traffic Loop", startTime: "08:00:00", playoutMode: "One", playbackOrder: "Chronological", guideMode: "Filler" }),
|
||||
mk({ _key: "b3", collectionType: "TelevisionSeason", title: "Sesame Street — Season 52", startTime: "08:15:00", playoutMode: "Duration", durationH: 1, durationM: 45 }),
|
||||
mk({ _key: "b4", collectionType: "Collection", title: "Pre-School Filler", startType: "Flexible", startTime: null, playoutMode: "Flood" }),
|
||||
],
|
||||
3: [
|
||||
mk({ _key: "c1", collectionType: "Collection", title: "80s Action Movies", startType: "Flexible", startTime: null, playoutMode: "Flood", playbackOrder: "Shuffle" }),
|
||||
mk({ _key: "c2", collectionType: "MultiCollection", title: "Cult Classics Mix", startType: "Flexible", startTime: null, playoutMode: "Flood", playbackOrder: "Shuffle" }),
|
||||
mk({ _key: "c3", collectionType: "Collection", title: "Weekend Trailers Reel", startType: "Flexible", startTime: null, playoutMode: "One", playbackOrder: "Shuffle", guideMode: "Filler" }),
|
||||
],
|
||||
4: [
|
||||
mk({ _key: "d1", collectionType: "RerunRerun", title: "World Tonight — Evening Bulletin", startTime: "18:00:00", playoutMode: "One", playbackOrder: "Chronological" }),
|
||||
mk({ _key: "d2", collectionType: "SmartCollection", title: "Late Edition Recap", startTime: "23:00:00", playoutMode: "One", playbackOrder: "Chronological" }),
|
||||
],
|
||||
5: [],
|
||||
};
|
||||
|
||||
const SOURCE_LABEL = { SearchQuery: "Search query", Playlist: "Playlist", RerunFirstRun: "Rerun collection", RerunRerun: "Rerun collection" };
|
||||
|
||||
function Fieldset({ legend, children }) {
|
||||
return (
|
||||
<fieldset style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", padding: "var(--space-6)", display: "flex", flexDirection: "column", gap: "var(--space-5)", minWidth: 0, margin: 0 }}>
|
||||
<legend style={{ padding: "0 var(--space-3)", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.05em", textTransform: "uppercase", color: "var(--text-disabled)" }}>{legend}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function FormGrid({ children }) {
|
||||
return <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-5)" }}>{children}</div>;
|
||||
}
|
||||
|
||||
function SelectedChip({ label, value }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, height: "var(--control-h)", padding: "0 10px", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)" }}>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{value}</span>
|
||||
<Button size="sm" variant="ghost">Change</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipField({ label, selected, more }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
{selected.length === 0 ? (
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)" }}>None selected</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{selected.map((s) => <Tag key={s} tone="accent" onRemove={() => {}}>{s}</Tag>)}
|
||||
{more ? <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", alignSelf: "center" }}>+{more} more available</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSection({ item }) {
|
||||
const type = item.collectionType;
|
||||
return (
|
||||
<Fieldset legend="Source">
|
||||
<Select label="Collection type" value={type} onChange={() => {}} options={["Collection", "MultiCollection", "SmartCollection", "Playlist", "TelevisionShow", "TelevisionSeason", "Artist", "RerunFirstRun", "RerunRerun", "SearchQuery"].map((v) => ({ value: v, label: humanize(v) }))} />
|
||||
{type === "SearchQuery" ? (
|
||||
<React.Fragment>
|
||||
<Input label="Search title" value={item.title.replace(/^Search: /, "").replace(/"/g, "")} onChange={() => {}} />
|
||||
<Input label="Search query" value='year >= 1990 AND year <= 2005 AND genre = "Christmas"' onChange={() => {}} />
|
||||
</React.Fragment>
|
||||
) : type === "Playlist" ? (
|
||||
<FormGrid>
|
||||
<Select label="Playlist group" value="Overnight Filler" onChange={() => {}} options={["Overnight Filler", "Bumpers", "Interstitials"]} />
|
||||
<Select label="Playlist" value={item.title} onChange={() => {}} options={[item.title]} />
|
||||
</FormGrid>
|
||||
) : type === "RerunFirstRun" || type === "RerunRerun" ? (
|
||||
<Select label={SOURCE_LABEL[type]} value={item.title} onChange={() => {}} options={[item.title]} />
|
||||
) : (
|
||||
<SelectedChip label={humanize(type)} value={item.title} />
|
||||
)}
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectorBody({ item, shuffle }) {
|
||||
const durationVisible = item.playoutMode === "Duration" || item.playoutMode === "Flood";
|
||||
const marathonVisible = item.playoutMode === "Marathon";
|
||||
const multipleVisible = item.playoutMode === "Multiple";
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-6)", flex: 1, minHeight: 0, overflow: "auto", padding: "var(--space-7)" }}>
|
||||
<SourceSection item={item} />
|
||||
|
||||
<Fieldset legend="Start">
|
||||
<FormGrid>
|
||||
<Select label="Start type" value={item.startType} onChange={() => {}} options={shuffle ? [{ value: "Flexible", label: "Flexible" }] : [{ value: "Fixed", label: "Fixed" }, { value: "Flexible", label: "Flexible" }]} />
|
||||
<Input label="Start time" type="time" value={item.startTime ? item.startTime.slice(0, 5) : ""} disabled={item.startType !== "Fixed"} onChange={() => {}} />
|
||||
<Select label="Fixed start behavior" value={item.startType === "Fixed" ? "Strict" : ""} disabled={item.startType !== "Fixed"} onChange={() => {}} options={[{ value: "", label: "Inherit" }, { value: "Strict", label: "Strict" }, { value: "Flexible", label: "Flexible" }]} />
|
||||
</FormGrid>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Playback">
|
||||
<FormGrid>
|
||||
<Select label="Playback order" value={item.playbackOrder} onChange={() => {}} options={["Chronological", "Shuffle", "Random"].map((v) => ({ value: v, label: v }))} />
|
||||
<Select label="Playout mode" value={item.playoutMode} onChange={() => {}} options={["One", "Multiple", "Duration", "Flood", "Marathon"].map((v) => ({ value: v, label: v }))} />
|
||||
</FormGrid>
|
||||
|
||||
{marathonVisible && (
|
||||
<FormGrid>
|
||||
<Select label="Marathon group by" value="Show" onChange={() => {}} options={["Show", "Season", "Artist"]} />
|
||||
<Input label="Batch size (empty plays all)" type="number" value="" placeholder="all" onChange={() => {}} />
|
||||
<Checkbox label="Shuffle groups" checked={true} onChange={() => {}} />
|
||||
<Checkbox label="Shuffle items" checked={false} onChange={() => {}} />
|
||||
</FormGrid>
|
||||
)}
|
||||
|
||||
{multipleVisible && (
|
||||
<FormGrid>
|
||||
<Select label="Multiple mode" value="Count" onChange={() => {}} options={["Count", "Duration"]} />
|
||||
<Input label="Multiple count" value="count + random(3)" placeholder="expression may use 'count' and 'random'" onChange={() => {}} />
|
||||
</FormGrid>
|
||||
)}
|
||||
|
||||
{!marathonVisible && !multipleVisible && (
|
||||
<FormGrid>
|
||||
<Select label="Multiple mode" value="" disabled onChange={() => {}} options={[{ value: "", label: "—" }]} />
|
||||
<Input label="Multiple count" value="" disabled placeholder="expression may use 'count' and 'random'" onChange={() => {}} />
|
||||
</FormGrid>
|
||||
)}
|
||||
|
||||
{durationVisible && (
|
||||
<React.Fragment>
|
||||
<FormGrid>
|
||||
<Input label="Duration (hours)" type="number" value={`${item.durationH}`} onChange={() => {}} />
|
||||
<Input label="Duration (minutes)" type="number" value={`${item.durationM}`} onChange={() => {}} />
|
||||
</FormGrid>
|
||||
<FormGrid>
|
||||
<Select label="Tail mode" value={item.tailMode} onChange={() => {}} options={["None", "Filler", "Truncate"]} />
|
||||
<Input label="Discard to fill attempts" type="number" value={`${item.discardAttempts}`} onChange={() => {}} />
|
||||
</FormGrid>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<Select label="Fill with group mode" value="" disabled onChange={() => {}} options={[{ value: "", label: "—" }]} />
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Filler">
|
||||
<FormGrid>
|
||||
<Select label="Pre-roll" value={item.filler.pre} onChange={() => {}} options={[item.filler.pre]} />
|
||||
<Select label="Mid-roll" value={item.filler.mid} onChange={() => {}} options={[item.filler.mid]} />
|
||||
<Select label="Post-roll" value={item.filler.post} onChange={() => {}} options={[item.filler.post]} />
|
||||
<Select label="Tail" value={item.filler.tail} onChange={() => {}} options={[item.filler.tail]} />
|
||||
<Select label="Fallback" value={item.filler.fallback} onChange={() => {}} options={[item.filler.fallback]} />
|
||||
</FormGrid>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Overrides">
|
||||
<Input label="Custom title" value={item.customTitle} placeholder="Optional guide title" onChange={() => {}} />
|
||||
<Select label="Guide mode" value={item.guideMode} onChange={() => {}} options={["Normal", "Filler"]} />
|
||||
<ChipField label="Watermarks" selected={item.watermarks} more={item.watermarks.length ? 2 : 3} />
|
||||
<ChipField label="Graphics elements" selected={item.graphics} more={item.graphics.length ? 1 : 4} />
|
||||
<FormGrid>
|
||||
<Select label="Preferred audio language" value={item.audioLang} onChange={() => {}} options={["(none)", "English", "French", "Japanese"]} />
|
||||
<Input label="Preferred audio title" value={item.audioTitle} onChange={() => {}} />
|
||||
</FormGrid>
|
||||
<FormGrid>
|
||||
<Select label="Preferred subtitle language" value={item.subtitleLang} onChange={() => {}} options={["(none)", "English", "French"]} />
|
||||
<Select label="Subtitle mode" value={item.subtitleMode} onChange={() => {}} options={["Inherit", "Forced", "Default", "None"]} />
|
||||
</FormGrid>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineupRow({ item, index, active, canReorder, isFirst, isLast, onSelect }) {
|
||||
const fixed = item.startType === "Fixed";
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "42px minmax(0,1fr)", gap: "var(--space-5)", marginBottom: "var(--space-5)" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4, paddingTop: 14, color: "var(--text-disabled)" }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: "var(--radius-pill)", border: `2px solid ${fixed ? "var(--ctv-accent)" : "var(--border-control)"}`, background: fixed ? "var(--ctv-accent)" : "transparent" }} />
|
||||
<span style={{ ...mono, fontSize: 9 }}>{fixed ? item.startTime.slice(0, 5) : "flows"}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{ position: "relative", display: "flex", flexDirection: "column", gap: "var(--space-3)", border: `1px solid ${active ? "var(--action-primary)" : "var(--border-hairline)"}`, borderRadius: "var(--radius-md)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface)", padding: "var(--space-5)", cursor: "pointer", transition: "background var(--dur-fast), border-color var(--dur-fast)" }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "16px 30px minmax(0,1fr) auto", alignItems: "center", gap: "var(--space-5)" }}>
|
||||
<Ico n="GripVertical" s={14} color="var(--text-disabled)" />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 22, height: 22, padding: "0 6px", borderRadius: "var(--radius-pill)", background: "var(--ctv-surface-3)", color: "var(--text-secondary)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)" }}>{index + 1}</span>
|
||||
<div style={{ minWidth: 0, display: "grid", gap: 3 }}>
|
||||
<strong style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{item.title}</strong>
|
||||
<small style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{humanize(item.collectionType)} · {item.playoutMode}</small>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)", flexWrap: "wrap", paddingLeft: 64 }}>
|
||||
<Badge tone="neutral">{item.playbackOrder}</Badge>
|
||||
{item.guideMode === "Filler" && <Badge tone="neutral">Hidden from guide</Badge>}
|
||||
</div>
|
||||
<div style={{ position: "absolute", right: 6, bottom: 6, display: "flex", alignItems: "center", gap: 2 }} onClick={(e) => e.stopPropagation()}>
|
||||
{canReorder && (
|
||||
<React.Fragment>
|
||||
<Tooltip label="Move up"><IconButton size="sm" disabled={isFirst} title="Move up"><Ico n="ChevronUp" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="Move down"><IconButton size="sm" disabled={isLast} title="Move down"><Ico n="ChevronDown" s={14} /></IconButton></Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<Tooltip label="Copy"><IconButton size="sm" title="Copy"><Ico n="Copy" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="Remove"><IconButton size="sm" title="Remove"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Schedules() {
|
||||
const [activeId, setActiveId] = React.useState(1);
|
||||
const [selectedKey, setSelectedKey] = React.useState("a1");
|
||||
const dirty = true;
|
||||
|
||||
const activeSchedule = SCHEDULES.find((s) => s.id === activeId);
|
||||
const items = ITEMS_BY_SCHEDULE[activeId] || [];
|
||||
const selectedItem = items.find((i) => i._key === selectedKey) || items[0] || null;
|
||||
const canReorder = !activeSchedule.shuffle;
|
||||
|
||||
const onSwitch = (id) => {
|
||||
setActiveId(id);
|
||||
const next = ITEMS_BY_SCHEDULE[id] || [];
|
||||
setSelectedKey(next[0] ? next[0]._key : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", padding: "16px 20px", gap: "var(--space-6)", overflow: "hidden" }}>
|
||||
{/* header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-6)", flex: "0 0 auto", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)", padding: "12px 20px" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}>
|
||||
<Ico n="CalendarClock" s={18} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{activeSchedule.name}</h2>
|
||||
<p style={{ margin: "4px 0 0", font: "var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{SCHEDULES.length} schedules · {items.length} item{items.length === 1 ? "" : "s"}
|
||||
{dirty && <React.Fragment> · <strong style={{ color: "var(--status-warn)" }}>unsaved changes</strong></React.Fragment>}
|
||||
</p>
|
||||
</div>
|
||||
<Select label="Active schedule" size="sm" value={`${activeId}`} onChange={(e) => onSwitch(Number(e.target.value))} options={SCHEDULES.map((s) => ({ value: `${s.id}`, label: s.name }))} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Plus" s={14} />}>Add schedule</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Pencil" s={14} />}>Edit</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Trash2" s={14} />}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-4)", flex: "0 0 auto", padding: "8px 12px", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-surface-2)" }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="Undo2" s={14} />}>Discard</Button>
|
||||
<Button size="sm" variant="primary" startIcon={<Ico n="Save" s={14} />}>Save</Button>
|
||||
</div>
|
||||
|
||||
{/* grid */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(420px,1fr) minmax(360px,460px)", gap: "var(--space-8)", flex: 1, minHeight: 0 }}>
|
||||
<section style={{ display: "flex", flexDirection: "column", minHeight: 0, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid var(--border-hairline)", padding: "10px 16px" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.05em", textTransform: "uppercase", color: "var(--text-disabled)" }}>
|
||||
Lineup{canReorder ? " · use arrows to reorder" : " · order fixed (shuffled schedule)"}
|
||||
</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>No schedule items</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "var(--space-6)" }}>
|
||||
{items.map((item, index) => (
|
||||
<LineupRow key={item._key} item={item} index={index} active={item._key === (selectedItem && selectedItem._key)} canReorder={canReorder} isFirst={index === 0} isLast={index === items.length - 1} onSelect={() => setSelectedKey(item._key)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--surface-card)" }}>
|
||||
{selectedItem ? (
|
||||
<React.Fragment>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-5)", borderBottom: "1px solid var(--border-hairline)", padding: "var(--space-7)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: "grid", gap: 4 }}>
|
||||
<strong style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{selectedItem.title}</strong>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{humanize(selectedItem.collectionType)}</span>
|
||||
</div>
|
||||
<Tooltip label="Copy item"><IconButton title="Copy item"><Ico n="Copy" s={15} /></IconButton></Tooltip>
|
||||
<Tooltip label="Remove item"><IconButton title="Remove item"><Ico n="Trash2" s={15} /></IconButton></Tooltip>
|
||||
</div>
|
||||
<InspectorBody item={selectedItem} shuffle={activeSchedule.shuffle} />
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Select or add a schedule item</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVSchedules = Schedules;
|
||||
})();
|
||||
@@ -0,0 +1,203 @@
|
||||
// Search screen — a query-driven results page: single search input in the action bar,
|
||||
// bulk add-all / select-mode controls, then a stack of media-kind sections (Movies, TV
|
||||
// Shows, Episodes, Music Videos, Songs, …) each rendered as a poster grid with a
|
||||
// "See all" affordance. Mirrors the Media Browse poster-card language.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Badge, Card, Input, Spinner, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const KIND_ICON = {
|
||||
movies: "Clapperboard",
|
||||
shows: "Tv",
|
||||
seasons: "Layers",
|
||||
episodes: "PlaySquare",
|
||||
artists: "Mic2",
|
||||
musicVideos: "Video",
|
||||
songs: "Music",
|
||||
otherVideos: "Film",
|
||||
images: "Image",
|
||||
remoteStreams: "Satellite",
|
||||
};
|
||||
|
||||
function hueOf(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
function poster(title, kind, extra) {
|
||||
return { title, kind, ...extra };
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
key: "movies", label: "Movies", slug: "movies", total: 14,
|
||||
items: [
|
||||
poster("Star Wars: A New Hope", "movies", { meta: "1977 · 2h 1m" }),
|
||||
poster("Starship Troopers", "movies", { meta: "1997 · 2h 9m" }),
|
||||
poster("A Star Is Born", "movies", { meta: "2018 · 2h 16m" }),
|
||||
poster("Stardust", "movies", { meta: "2007 · 2h 7m" }),
|
||||
poster("Star Trek: First Contact", "movies", { meta: "1996 · 1h 51m" }),
|
||||
poster("The Lonely Star", "movies", { meta: "1965 · 1h 38m" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "shows", label: "TV Shows", slug: "shows", total: 3,
|
||||
items: [
|
||||
poster("Star Trek: The Next Generation", "shows", { meta: "7 seasons" }),
|
||||
poster("Battlestar Galactica", "shows", { meta: "4 seasons" }),
|
||||
poster("Stargate SG-1", "shows", { meta: "10 seasons" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "episodes", label: "Episodes", slug: "episodes", total: 42,
|
||||
items: [
|
||||
poster("The Best of Both Worlds", "episodes", { meta: "TNG · S03E26" }),
|
||||
poster("33", "episodes", { meta: "Battlestar Galactica · S01E01" }),
|
||||
poster("Children of the Gods", "episodes", { meta: "Stargate SG-1 · S01E01" }),
|
||||
poster("Yesterday's Enterprise", "episodes", { meta: "TNG · S03E15" }),
|
||||
poster("Exodus, Part 2", "episodes", { meta: "Battlestar Galactica · S03E04" }),
|
||||
poster("Redemption, Part 1", "episodes", { meta: "TNG · S04E26" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "musicVideos", label: "Music Videos", slug: "music-videos", total: 2,
|
||||
items: [
|
||||
poster("Lucky Star", "musicVideos", { meta: "Madonna · 1984" }),
|
||||
poster("Starman", "musicVideos", { meta: "David Bowie · 1972" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "songs", label: "Songs", slug: "songs", total: 6,
|
||||
items: [
|
||||
poster("Starman", "songs", { meta: "David Bowie · Ziggy Stardust" }),
|
||||
poster("Lucky Star", "songs", { meta: "Madonna · Madonna" }),
|
||||
poster("Stardust", "songs", { meta: "Nat King Cole · Standards" }),
|
||||
poster("Starlight", "songs", { meta: "Muse · Black Holes and Revelations" }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function PosterCard({ item }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const hue = hueOf(item.title);
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 7, cursor: "pointer" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative", width: "100%", height: 150, borderRadius: "var(--radius-sm)",
|
||||
overflow: "hidden", background: `linear-gradient(160deg, hsl(${hue} 24% 17%), hsl(${hue} 26% 12%))`,
|
||||
boxShadow: hover ? "var(--shadow-md)" : "var(--shadow-sm)",
|
||||
transform: hover ? "translateY(-2px)" : "none", transition: "transform var(--dur-fast), box-shadow var(--dur-fast)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "absolute", inset: 0, backgroundImage: `repeating-linear-gradient(135deg, hsl(${hue} 22% 10% / .55) 0 1px, transparent 1px 9px)` }} />
|
||||
<span style={{ position: "absolute", top: 8, left: 8, display: "inline-flex", color: `hsl(${hue} 30% 68%)` }}>
|
||||
<Ico n={KIND_ICON[item.kind]} s={15} />
|
||||
</span>
|
||||
{hover && (
|
||||
<div style={{ position: "absolute", top: 7, right: 7, display: "inline-flex", alignItems: "center", justifyContent: "center", width: 26, height: 26, borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-sm)" }}>
|
||||
<Ico n="Plus" s={14} color="var(--text-secondary)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.meta}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ group }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 26 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{group.label}</h2>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>
|
||||
{group.total} result{group.total === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{group.total > group.items.length && (
|
||||
<Button size="sm" variant="ghost" endIcon={<Ico n="ChevronRight" s={13} />}>See all</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))", gap: 14 }}>
|
||||
{group.items.map((item, i) => <PosterCard key={i} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Search() {
|
||||
const [q, setQ] = React.useState("star");
|
||||
const [selectMode, setSelectMode] = React.useState(false);
|
||||
const hasQuery = q.trim().length > 0;
|
||||
const totalMatches = GROUPS.reduce((sum, g) => sum + g.total, 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<div style={{ width: 320 }}>
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search movies, shows, episodes, music…"
|
||||
leadingIcon={<Ico n="Search" s={14} />}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span style={{ flex: 1 }} />
|
||||
{hasQuery && (
|
||||
<React.Fragment>
|
||||
<Button size="sm" variant={selectMode ? "primary" : "secondary"} startIcon={<Ico n="CheckSquare" s={14} />} onClick={() => setSelectMode((s) => !s)}>Select</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="FolderPlus" s={14} />}>Add all to collection</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ListVideo" s={14} />}>Add all to playlist</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Save" s={14} />}>Save as smart collection</Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* selection bar */}
|
||||
{selectMode && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 20px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-accent-soft)", flex: "0 0 auto" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}><span style={mono}>3</span> selected</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="FolderPlus" s={14} />}>Add to collection</Button>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ListVideo" s={14} />}>Add to playlist</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="X" s={14} />}>Clear selection</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "18px 20px" }}>
|
||||
{!hasQuery && (
|
||||
<Card>
|
||||
<div style={{ padding: "28px 0", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Type a query to search across every media kind.
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasQuery && (
|
||||
<React.Fragment>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 18, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span><span style={mono}>{totalMatches}</span> results for “{q.trim()}”</span>
|
||||
</div>
|
||||
{GROUPS.map((g) => <Section key={g.key} group={g} />)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVSearch = Search;
|
||||
})();
|
||||
@@ -1,36 +1,50 @@
|
||||
// Settings screen — GROUND-UP: left sub-nav rail + focused form panes, one pane
|
||||
// per settings group (mirrors /api/settings/*). Dense "system settings" rows:
|
||||
// label + help text left, control right. A floating save bar appears when dirty.
|
||||
// Settings screen — left sub-nav rail (8 sections) + focused form panes, one pane
|
||||
// per settings group (mirrors /api/settings/*). Dense "system settings" rows: label
|
||||
// + help text left, control right, inside flush Cards. A floating save bar appears
|
||||
// once anything is dirty.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Card, Button, IconButton, Badge, Input, Select, Switch, Tooltip, StatusDot, Tag } = NS;
|
||||
const { Card, Button, IconButton, Badge, Tag, Input, Select, Switch, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "general", label: "General", icon: "SlidersHorizontal", hint: "Theme & language" },
|
||||
{ id: "streaming",label: "Streaming", icon: "Clapperboard", hint: "FFmpeg & transcoding" },
|
||||
{ id: "playout", label: "Playout", icon: "ListVideo", hint: "Build defaults" },
|
||||
{ id: "xmltv", label: "Guide (XMLTV)",icon: "CalendarDays", hint: "EPG output" },
|
||||
{ id: "scanner", label: "Scanner", icon: "Radar", hint: "Library refresh" },
|
||||
{ id: "logging", label: "Logging", icon: "ScrollText", hint: "Log levels" },
|
||||
{ id: "system", label: "System", icon: "Server", hint: "HDHR, sources, about" },
|
||||
{ id: "general", label: "General", icon: "SlidersHorizontal", hint: "Theme & language" },
|
||||
{ id: "streaming", label: "Streaming", icon: "Clapperboard", hint: "FFmpeg & transcoding" },
|
||||
{ id: "playout", label: "Playout", icon: "ListVideo", hint: "Build defaults" },
|
||||
{ id: "xmltv", label: "Guide (XMLTV)", icon: "CalendarDays", hint: "EPG output" },
|
||||
{ id: "iptv", label: "IPTV", icon: "Radio", hint: "M3U & stream URLs" },
|
||||
{ id: "scanner", label: "Scanner", icon: "Radar", hint: "Library refresh" },
|
||||
{ id: "logging", label: "Logging", icon: "ScrollText", hint: "Log levels" },
|
||||
{ id: "system", label: "System", icon: "Server", hint: "HDHR, sources, about" },
|
||||
];
|
||||
|
||||
const PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "480p H.264"];
|
||||
const LOG_LEVELS = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"];
|
||||
const RESOLUTIONS = [
|
||||
{ id: 1, w: 1280, h: 720, tag: "720p" },
|
||||
{ id: 2, w: 1920, h: 1080, tag: "1080p" },
|
||||
{ id: 3, w: 1920, h: 820, tag: "custom" },
|
||||
{ id: 4, w: 2560, h: 1080, tag: "custom" },
|
||||
];
|
||||
const SOURCES = [
|
||||
{ id: 1, kind: "Local", name: "Movies", address: "local", lastScan: "Last scan Jul 17, 9:12 AM" },
|
||||
{ id: 2, kind: "Jellyfin", name: "Jellyfin — Main", address: "192.168.1.50:8096", lastScan: "Last scan Jul 16, 11:40 PM" },
|
||||
{ id: 3, kind: "Local", name: "Anime Library", address: "local", lastScan: null },
|
||||
];
|
||||
|
||||
const DEFAULTS = {
|
||||
theme: "Dark", language: "English (en-US)",
|
||||
theme: "Dark", language: "en-US",
|
||||
ffmpegPath: "/usr/bin/ffmpeg", ffprobePath: "/usr/bin/ffprobe",
|
||||
defaultProfile: "1080p H.264", preferredAudioLanguage: "English (eng)",
|
||||
defaultProfile: "1080p H.264", preferredAudioLanguage: "eng",
|
||||
useEmbeddedSubtitles: true, extractEmbeddedSubtitles: false,
|
||||
probeInterlaced: true, saveReports: false,
|
||||
globalWatermark: "(none)", globalFallbackFiller: "Retro Bumpers",
|
||||
hlsIdleTimeout: "60", hlsWorkAhead: "1", hlsInitialSegments: "1",
|
||||
hlsDirectFormat: "MPEG-TS", mpegTsScript: "(default template)",
|
||||
hlsDirectFormat: "MPEG-TS", mpegTsScript: "",
|
||||
playoutDays: "2", skipMissing: true, scriptedTimeout: "30",
|
||||
xmltvDays: "2", xmltvTimeZone: "Local", xmltvBlockBehavior: "Split time evenly",
|
||||
iptvBaseUrl: "http://192.168.1.99:8409",
|
||||
refreshHours: "6",
|
||||
logDefault: "Information", logScanning: "Information", logScheduling: "Information",
|
||||
logSearching: "Information", logStreaming: "Information", logHttp: "Warning",
|
||||
@@ -64,6 +78,15 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Flush non-Row line inside a Card — icon + main content + trailing bits.
|
||||
function FlushRow({ children, first }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyCallout({ children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)" }}>
|
||||
@@ -102,9 +125,8 @@
|
||||
<Row first label="Theme" help="Legacy UI color scheme.">
|
||||
<Select size="sm" fullWidth value={v.theme} onChange={(e) => set("theme", e.target.value)} options={["Dark", "Light"]} />
|
||||
</Row>
|
||||
<Row label="Language" help="Display language for the legacy UI.">
|
||||
<Select size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)}
|
||||
options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} />
|
||||
<Row label="Language" help="Display language for the legacy UI, as a culture code (e.g. en-US).">
|
||||
<Input size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)} placeholder="en-US" />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
@@ -114,7 +136,7 @@
|
||||
function StreamingPane({ v, set }) {
|
||||
const [resW, setResW] = React.useState("");
|
||||
const [resH, setResH] = React.useState("");
|
||||
const [customRes, setCustomRes] = React.useState([{ id: 1, w: 1920, h: 820 }, { id: 2, w: 2560, h: 1080 }]);
|
||||
const [resolutions, setResolutions] = React.useState(RESOLUTIONS);
|
||||
return (
|
||||
<Pane title="Streaming" subtitle="FFmpeg engine, transcoding defaults and HLS session tuning.">
|
||||
<Card title="FFmpeg" padded={false}>
|
||||
@@ -127,9 +149,8 @@
|
||||
<Row label="Default profile" help="Transcoding profile for new channels.">
|
||||
<Select size="sm" fullWidth value={v.defaultProfile} onChange={(e) => set("defaultProfile", e.target.value)} options={PROFILES} />
|
||||
</Row>
|
||||
<Row label="Preferred audio language" help="Track picked when media has multiple audio languages.">
|
||||
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)}
|
||||
options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} />
|
||||
<Row label="Preferred audio language" help="ISO 639-2 code of the track picked when media has multiple audio languages (e.g. eng).">
|
||||
<Input size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)} placeholder="eng" style={mono} />
|
||||
</Row>
|
||||
<Row label="Use embedded subtitles" help="Burn in / pass through subtitles found in media files.">
|
||||
<Switch size="sm" checked={v.useEmbeddedSubtitles} onChange={(x) => set("useEmbeddedSubtitles", x)} />
|
||||
@@ -170,28 +191,30 @@
|
||||
<Select size="sm" fullWidth value={v.hlsDirectFormat} onChange={(e) => set("hlsDirectFormat", e.target.value)} options={["MPEG-TS", "MP4", "MKV"]} />
|
||||
</Row>
|
||||
<Row label="Default MPEG-TS script" help="Channels in MPEG-TS mode use this script unless overridden.">
|
||||
<Select size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} />
|
||||
<Input size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} placeholder="(default template)" style={mono} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card title="Custom resolutions" subtitle="Extra target resolutions selectable in FFmpeg profiles" padded={false}>
|
||||
{customRes.map((r, i) => (
|
||||
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
{resolutions.map((r, i) => (
|
||||
<FlushRow key={r.id} first={i === 0}>
|
||||
<Ico n="Proportions" s={15} color="var(--text-disabled)" />
|
||||
<span style={{ flex: 1, ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{r.w} × {r.h}</span>
|
||||
<Tag>custom</Tag>
|
||||
<IconButton size="sm" variant="ghost" title="Delete resolution" onClick={() => setCustomRes(customRes.filter((x) => x.id !== r.id))}>
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Tag>{r.tag}</Tag>
|
||||
{r.tag === "custom" && (
|
||||
<IconButton size="sm" variant="ghost" title={`Delete ${r.w}×${r.h}`} onClick={() => setResolutions(resolutions.filter((x) => x.id !== r.id))}>
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
)}
|
||||
</FlushRow>
|
||||
))}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: customRes.length ? "1px solid var(--border-hairline)" : "none", background: "var(--ctv-bg-sunken)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<Input size="sm" type="number" placeholder="width" value={resW} onChange={(e) => setResW(e.target.value)} style={{ width: 96, ...mono }} />
|
||||
<span style={{ color: "var(--text-disabled)" }}>×</span>
|
||||
<Input size="sm" type="number" placeholder="height" value={resH} onChange={(e) => setResH(e.target.value)} style={{ width: 96, ...mono }} />
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={13} />} disabled={!resW || !resH}
|
||||
onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
|
||||
<Button size="sm" variant="secondary" disabled={!resW || !resH}
|
||||
onClick={() => { setResolutions([...resolutions, { id: Date.now(), w: +resW, h: +resH, tag: "custom" }]); setResW(""); setResH(""); }}>
|
||||
Add resolution
|
||||
</Button>
|
||||
</div>
|
||||
@@ -200,15 +223,14 @@
|
||||
<Card title="FFmpeg profiles" subtitle="Fine-grained transcoding presets" padded={false}
|
||||
actions={<Badge tone="neutral">{PROFILES.length} profiles</Badge>}>
|
||||
{PROFILES.map((p, i) => (
|
||||
<div key={p} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<FlushRow key={p} first={i === 0}>
|
||||
<Ico n="Cpu" s={15} color="var(--ctv-accent)" />
|
||||
<span style={{ flex: 1, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{p}</span>
|
||||
{p === v.defaultProfile && <Badge tone="accent">Default</Badge>}
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{i + 1} ch</span>
|
||||
</div>
|
||||
</FlushRow>
|
||||
))}
|
||||
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Manage FFmpeg profiles</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Pane>
|
||||
@@ -251,6 +273,19 @@
|
||||
);
|
||||
}
|
||||
|
||||
function IptvPane({ v, set }) {
|
||||
return (
|
||||
<Pane title="IPTV" subtitle="How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs.">
|
||||
<Card padded={false}>
|
||||
<Row first control={340} label="Advertised base URL"
|
||||
help="Absolute base URL clients should use to reach this server (e.g. http://192.168.1.99:8409 or https://tv.example.com/etv). Leave empty to use the address of each incoming request.">
|
||||
<Input size="sm" fullWidth value={v.iptvBaseUrl} onChange={(e) => set("iptvBaseUrl", e.target.value)} placeholder="(use request origin)" style={mono} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function ScannerPane({ v, set }) {
|
||||
return (
|
||||
<Pane title="Scanner" subtitle="Background scanning of local libraries.">
|
||||
@@ -260,7 +295,7 @@
|
||||
</Row>
|
||||
</Card>
|
||||
{v.refreshHours === "0" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--status-warn-soft)", border: "1px solid transparent" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid transparent" }}>
|
||||
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Automatic scanning is disabled — libraries only update when scanned manually.
|
||||
@@ -273,12 +308,12 @@
|
||||
|
||||
function LoggingPane({ v, set }) {
|
||||
const CATS = [
|
||||
{ key: "logDefault", label: "Default", help: "Everything without a more specific category." },
|
||||
{ key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." },
|
||||
{ key: "logDefault", label: "Default", help: "Everything without a more specific category." },
|
||||
{ key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." },
|
||||
{ key: "logScheduling", label: "Scheduling", help: "Playout builds and schedule evaluation." },
|
||||
{ key: "logSearching", label: "Searching", help: "Search index and queries." },
|
||||
{ key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." },
|
||||
{ key: "logHttp", label: "HTTP", help: "ASP.NET request logging." },
|
||||
{ key: "logSearching", label: "Searching", help: "Search index and queries." },
|
||||
{ key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." },
|
||||
{ key: "logHttp", label: "HTTP", help: "ASP.NET request logging." },
|
||||
];
|
||||
return (
|
||||
<Pane title="Logging" subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.">
|
||||
@@ -294,6 +329,7 @@
|
||||
}
|
||||
|
||||
function SystemPane({ v, set }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
|
||||
return (
|
||||
<Pane title="System" subtitle="HDHomeRun emulation, connected media sources and server info.">
|
||||
@@ -304,22 +340,23 @@
|
||||
<Row label="Device UUID" help="Identity presented to HDHR clients." control={340}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", justifyContent: "flex-end" }}>
|
||||
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis" }}>{UUID}</span>
|
||||
<IconButton size="sm" variant="ghost" title="Copy UUID"><Ico n="Copy" s={13} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Copy UUID" onClick={() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }}>
|
||||
<Ico n={copied ? "Check" : "Copy"} s={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card title="Media sources" subtitle="Read-only — add or edit connections in the legacy UI" padded={false}>
|
||||
{D.sources.map((s, i) => (
|
||||
<div key={s.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
{SOURCES.map((s, i) => (
|
||||
<FlushRow key={s.id} first={i === 0}>
|
||||
<Ico n={s.kind === "Local" ? "HardDrive" : "Cast"} s={15} color="var(--text-secondary)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</span>
|
||||
<span style={{ marginLeft: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.detail}</span>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{s.address}</div>
|
||||
</div>
|
||||
<StatusDot status={s.status === "connected" ? "ok" : "warn"} size={7} />
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", width: 62, textAlign: "right" }}>{s.lastScan}</span>
|
||||
</div>
|
||||
{s.lastScan && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", whiteSpace: "nowrap" }}>{s.lastScan}</span>}
|
||||
</FlushRow>
|
||||
))}
|
||||
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
|
||||
@@ -327,12 +364,21 @@
|
||||
</Card>
|
||||
|
||||
<Card title="About" padded={false}>
|
||||
<Row first label="Version" control={220}>
|
||||
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.4.0</span>
|
||||
<Row first control={220} label="Version">
|
||||
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.8.0</span>
|
||||
</Row>
|
||||
<Row label="Health checks" help="14 checks — 2 warnings." control={220}>
|
||||
<Row control={220} label="Health checks" help="14 checks — 2 warnings">
|
||||
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Dashboard</Button>
|
||||
</Row>
|
||||
<Row control={220} label="Classic UI" help="Blocks/decos/templates and playout editors still live here.">
|
||||
<Button size="sm" variant="secondary" endIcon={<Ico n="ExternalLink" s={13} />}>Open Classic UI</Button>
|
||||
</Row>
|
||||
<Row control={220} label="Logs" help="Recent server log entries with level and free-text filtering.">
|
||||
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Logs</Button>
|
||||
</Row>
|
||||
<Row control={220} label="Troubleshooting" help="Version, environment, hardware and FFmpeg capability diagnostics.">
|
||||
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Troubleshooting</Button>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
@@ -350,7 +396,7 @@
|
||||
|
||||
const PANES = {
|
||||
general: GeneralPane, streaming: StreamingPane, playout: PlayoutPane,
|
||||
xmltv: XmltvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
|
||||
xmltv: XmltvPane, iptv: IptvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
|
||||
};
|
||||
const PaneCmp = PANES[section];
|
||||
|
||||
@@ -387,12 +433,12 @@
|
||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
|
||||
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
{justSaved ? (
|
||||
<>
|
||||
<React.Fragment>
|
||||
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Settings saved</span>
|
||||
</>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<>
|
||||
<React.Fragment>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
|
||||
</span>
|
||||
@@ -402,7 +448,7 @@
|
||||
onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Setup screen — centered card-based first-run setup wizard, shell-less.
|
||||
// Shows either the initial admin account creation form or a post-setup confirmation state.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Card, Input } = NS;
|
||||
const Ico = window.Ico;
|
||||
|
||||
function Setup() {
|
||||
const [state, setState] = React.useState('form'); // 'form' or 'already-configured'
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [confirm, setConfirm] = React.useState('');
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
const passwordsMatch = password.length > 0 && password === confirm;
|
||||
const canSubmit = username.trim().length > 0 && passwordsMatch;
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (canSubmit) {
|
||||
// Mock: simulate setup success
|
||||
setState('form'); // Keep on form for mockup (would normally hand off to app)
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e) => {
|
||||
setUsername(e.target.value);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleConfirmChange = (e) => {
|
||||
setConfirm(e.target.value);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '24px',
|
||||
background: 'var(--ctv-bg)',
|
||||
}}
|
||||
>
|
||||
{state === 'form' ? (
|
||||
<Card
|
||||
padded
|
||||
title="Welcome to ChicoryTV"
|
||||
subtitle="Create the first administrator account"
|
||||
style={{ maxWidth: 420, width: '100%' }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<Input
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={handleUsernameChange}
|
||||
placeholder="Choose a username"
|
||||
leadingIcon={<Ico n="User" s={14} />}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
placeholder="Choose a password"
|
||||
type="password"
|
||||
leadingIcon={<Ico n="KeyRound" s={14} />}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm password"
|
||||
value={confirm}
|
||||
onChange={handleConfirmChange}
|
||||
placeholder="Re-enter the password"
|
||||
type="password"
|
||||
leadingIcon={<Ico n="KeyRound" s={14} />}
|
||||
error={confirm.length > 0 && !passwordsMatch ? 'Passwords do not match.' : null}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--ctv-error-soft)',
|
||||
font: 'var(--text-sm)/1.4 var(--font-sans)',
|
||||
color: 'var(--status-error)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
disabled={!canSubmit}
|
||||
startIcon={<Ico n="UserPlus" s={14} />}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
padded
|
||||
title="Already configured"
|
||||
style={{ maxWidth: 420, width: '100%' }}
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--ctv-warn-soft)',
|
||||
font: 'var(--text-sm)/1.4 var(--font-sans)',
|
||||
color: 'var(--status-warn)',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
This server has already been set up. Reload to sign in.
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
startIcon={<Ico n="RotateCcw" s={14} />}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVSetup = Setup;
|
||||
})();
|
||||
@@ -4,22 +4,11 @@
|
||||
const { NavItem, NavSection, Input, IconButton, StatusDot, Button } = NS;
|
||||
const Ico = window.Ico;
|
||||
|
||||
const NAV = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: "LayoutDashboard" },
|
||||
{ id: "channels", label: "Channels", icon: "Tv" },
|
||||
{ id: "builder", label: "New Channel", icon: "Plus" },
|
||||
{ id: "autoTune", label: "Auto-Tune", icon: "Sparkles" },
|
||||
{ id: "guide", label: "Guide", icon: "LayoutGrid" },
|
||||
{ id: "schedules", label: "Schedules", icon: "CalendarClock" },
|
||||
{ id: "playouts", label: "Playouts", icon: "ListVideo", badge: 3 },
|
||||
];
|
||||
const NAV_MEDIA = [
|
||||
{ id: "collections", label: "Collections", icon: "FolderTree" },
|
||||
{ id: "libraries", label: "Libraries", icon: "Library" },
|
||||
];
|
||||
const NAV_SYS = [
|
||||
{ id: "settings", label: "Settings", icon: "Settings" },
|
||||
];
|
||||
// Nav is built from the canonical inventory (screens.js) so it can never drift from the
|
||||
// screen map in app.html. A per-nav badge on "playouts" is kept as a demo affordance.
|
||||
const SCREENS = window.CTV_SCREENS || [];
|
||||
const GROUPS = window.CTV_NAV_GROUPS || [{ key: "primary", label: null }];
|
||||
const BADGES = { playouts: 3 };
|
||||
|
||||
function Sidebar({ view, setView }) {
|
||||
return (
|
||||
@@ -34,19 +23,20 @@
|
||||
Chicory<span style={{ color: "var(--ctv-accent)" }}>TV</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav style={{ flex: 1, overflow: "auto", padding: "8px 8px 16px" }}>
|
||||
{NAV.map((n) => (
|
||||
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.label} active={view === n.id}
|
||||
badge={n.badge} badgeTone="warn" onClick={() => setView(n.id)} />
|
||||
))}
|
||||
<NavSection>Media</NavSection>
|
||||
{NAV_MEDIA.map((n) => (
|
||||
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.label} active={view === n.id} onClick={() => setView(n.id)} />
|
||||
))}
|
||||
<NavSection>System</NavSection>
|
||||
{NAV_SYS.map((n) => (
|
||||
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.label} active={view === n.id} onClick={() => setView(n.id)} />
|
||||
))}
|
||||
<nav className="ctv-hide-sb" style={{ flex: 1, overflow: "auto", padding: "8px 8px 16px" }}>
|
||||
{GROUPS.map((g) => {
|
||||
const items = SCREENS.filter((s) => s.group === g.key);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<React.Fragment key={g.key}>
|
||||
{g.label ? <NavSection>{g.label}</NavSection> : null}
|
||||
{items.map((n) => (
|
||||
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.title} active={view === n.id}
|
||||
badge={BADGES[n.id]} badgeTone="warn" onClick={() => setView(n.id)} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div style={{ padding: "12px 14px", borderTop: "1px solid var(--border-hairline)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// Templates screen — a group'd list of daily/weekly block templates (list view) with a detail
|
||||
// editor view (name + block-group/block/start-time picker + a time-ordered items table).
|
||||
(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 GROUPS = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Weekday Rotation",
|
||||
templates: [
|
||||
{ id: 101, name: "Weekday Daytime" },
|
||||
{ id: 102, name: "Weekday Primetime" },
|
||||
{ id: 103, name: "Weekday Overnight" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Weekend Rotation",
|
||||
templates: [
|
||||
{ id: 201, name: "Saturday Marathon" },
|
||||
{ id: 202, name: "Sunday Wind-down" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Holiday",
|
||||
templates: [{ id: 301, name: "Christmas Eve Block" }],
|
||||
},
|
||||
{ id: 4, name: "Seasonal Specials", templates: [] },
|
||||
];
|
||||
|
||||
const TEMPLATE_ITEMS = [
|
||||
{ start: "00:00", end: "02:00", block: "Overnight Cartoons" },
|
||||
{ start: "02:00", end: "06:00", block: "Late Night Sitcoms" },
|
||||
{ start: "06:00", end: "09:00", block: "Morning News Loop" },
|
||||
{ start: "09:00", end: "12:00", block: "Daytime Talk" },
|
||||
{ start: "12:00", end: "15:00", block: "Afternoon Movie" },
|
||||
{ start: "15:00", end: "18:00", block: "80s Block" },
|
||||
{ start: "18:00", end: "20:00", block: "Evening News" },
|
||||
{ start: "20:00", end: "24:00", block: "Primetime Drama" },
|
||||
];
|
||||
|
||||
const GROUP_OPTIONS = [
|
||||
{ label: "Select a block group…", value: "" },
|
||||
{ label: "Cartoons", value: "cartoons" },
|
||||
{ label: "News", value: "news" },
|
||||
{ label: "Drama", value: "drama" },
|
||||
];
|
||||
const BLOCK_OPTIONS = [
|
||||
{ label: "Select a block…", value: "" },
|
||||
{ label: "Tom & Jerry Marathon", value: "tj" },
|
||||
{ label: "World Tonight", value: "wt" },
|
||||
{ label: "80s Synthpop Hour", value: "synth" },
|
||||
];
|
||||
const START_OPTIONS = ["00:00", "00:05", "00:10", "06:00", "12:00", "18:00", "20:00"].map((t) => ({
|
||||
label: t,
|
||||
value: t,
|
||||
}));
|
||||
|
||||
const rowFlush = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0 16px",
|
||||
height: 44,
|
||||
borderTop: "1px solid var(--border-hairline)",
|
||||
};
|
||||
|
||||
function ListView({ onOpen }) {
|
||||
const totalTemplates = GROUPS.reduce((n, g) => n + g.templates.length, 0);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<Badge tone="neutral">{totalTemplates} templates</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>
|
||||
New group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<Input leadingIcon={<Ico n="Search" s={14} />} placeholder="Search for templates…" value="" fullWidth />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{GROUPS.map((g) => (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>
|
||||
New template
|
||||
</Button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete group">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{g.templates.length === 0 ? (
|
||||
<div style={{ padding: "18px 16px", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
No templates in this group.
|
||||
</div>
|
||||
) : (
|
||||
g.templates.map((t, i) => (
|
||||
<div key={t.id} style={i === 0 ? { ...rowFlush, borderTop: "none" } : rowFlush}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(g, t)}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)",
|
||||
color: "var(--ctv-accent)",
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
<IconButton size="sm" variant="ghost" title="Copy template">
|
||||
<Ico n="Copy" s={14} />
|
||||
</IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete template">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorView({ group, template, onBack }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>
|
||||
All templates
|
||||
</Button>
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{group.name} / Edit template
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>
|
||||
Save template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card padded={false} title="Template">
|
||||
<div style={{ ...rowFlush, borderTop: "none", height: 52 }}>
|
||||
<div style={{ width: 220, font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Template group
|
||||
</div>
|
||||
<div style={{ flex: "0 0 360px" }}>
|
||||
<Input disabled size="sm" value={group.name} fullWidth />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...rowFlush, height: 52 }}>
|
||||
<div style={{ width: 220, font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Template name
|
||||
</div>
|
||||
<div style={{ flex: "0 0 360px" }}>
|
||||
<Input size="sm" value={template.name} fullWidth />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Add content">
|
||||
<div style={{ display: "flex", gap: 12, padding: 16, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<Select label="Block group" value="" options={GROUP_OPTIONS} />
|
||||
</div>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<Select label="Block" value="" options={BLOCK_OPTIONS} />
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<Select label="Start time" value="00:00" options={START_OPTIONS} />
|
||||
</div>
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Template items">
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "0 16px",
|
||||
height: 32,
|
||||
font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--text-disabled)",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
Time
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "0 16px",
|
||||
height: 32,
|
||||
font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--text-disabled)",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
Block
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
width: 50,
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TEMPLATE_ITEMS.map((it, i) => (
|
||||
<tr key={i} style={{ borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<td style={{ ...mono, padding: "0 16px", height: 44, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>
|
||||
{it.start}-{it.end}
|
||||
</td>
|
||||
<td style={{ padding: "0 16px", height: 44, font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{it.block}
|
||||
</td>
|
||||
<td style={{ padding: "0 16px", textAlign: "right" }}>
|
||||
<IconButton size="sm" variant="ghost" title="Remove">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Templates() {
|
||||
const [open, setOpen] = React.useState(null); // { group, template } | null
|
||||
|
||||
if (open) {
|
||||
return <EditorView group={open.group} template={open.template} onBack={() => setOpen(null)} />;
|
||||
}
|
||||
return <ListView onOpen={(group, template) => setOpen({ group, template })} />;
|
||||
}
|
||||
|
||||
window.CTVTemplates = Templates;
|
||||
})();
|
||||
@@ -0,0 +1,161 @@
|
||||
// TraktLists screen — a list of synced Trakt.tv lists with match-status tracking,
|
||||
// per-row actions (edit / re-match / view matches / delete), and a slide-in detail
|
||||
// editor for auto-refresh + playlist-generation toggles. Static mockup, list view.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Switch, Spinner, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const LISTS = [
|
||||
{ id: 1, slug: "arnold-schwarzenegger-movies", name: "Arnold Schwarzenegger Movies", matched: 42, total: 42 },
|
||||
{ id: 2, slug: "best-of-the-80s", name: "Best of the 80s", matched: 118, total: 130 },
|
||||
{ id: 3, slug: "criterion-collection-essentials", name: "Criterion Collection Essentials", matched: 76, total: 90 },
|
||||
{ id: 4, slug: "holiday-classics", name: "Holiday Classics", matched: 24, total: 24 },
|
||||
{ id: 5, slug: "oscar-winners-2020s", name: "Oscar Winners — 2020s", matched: 9, total: 31 },
|
||||
{ id: 6, slug: "studio-ghibli-complete", name: "Studio Ghibli — Complete", matched: 22, total: 22 },
|
||||
{ id: 7, slug: "underrated-sci-fi", name: "Underrated Sci-Fi", matched: 0, total: 58 },
|
||||
];
|
||||
|
||||
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: 52, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
|
||||
|
||||
function matchTone(matched, total) {
|
||||
if (total === 0) return "neutral";
|
||||
if (matched === 0) return "warn";
|
||||
if (matched >= total) return "ok";
|
||||
return "accent";
|
||||
}
|
||||
|
||||
function ListRow({ row, onOpen }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const tone = matchTone(row.matched, row.total);
|
||||
return (
|
||||
<tr onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{ borderTop: "1px solid var(--border-hairline)", background: hover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{row.slug}</td>
|
||||
<td style={td}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{row.name}</span>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<Badge tone={tone}>{row.matched} of {row.total}</Badge>
|
||||
<div style={{ height: 3, width: 90, borderRadius: 999, background: "var(--ctv-surface-3)", overflow: "hidden" }}>
|
||||
<div style={{ width: (row.total ? (row.matched / row.total) * 100 : 0) + "%", height: "100%", background: tone === "ok" ? "var(--status-ok)" : tone === "warn" ? "var(--status-warn)" : "var(--ctv-accent)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...td, width: 150 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 2, opacity: hover ? 1 : 0.55, transition: "opacity var(--dur-fast)" }}>
|
||||
<Tooltip label="Edit Trakt list properties"><IconButton size="sm" title="Edit" onClick={onOpen}><Ico n="Pencil" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="Match Trakt list items"><IconButton size="sm" title="Match"><Ico n="RefreshCw" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="View matched items"><IconButton size="sm" title="View matched items"><Ico n="Search" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="Delete Trakt list"><IconButton size="sm" title="Delete"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function TraktListEditor({ row, onBack }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All Trakt lists</Button>
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{row.name}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px" }}>
|
||||
<Card>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxWidth: 480 }}>
|
||||
<Input disabled label="Slug" value={row.slug} fullWidth />
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }} title="Automatically refresh this list's items">
|
||||
<Switch checked size="sm" />
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto refresh</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }} title="Generate a system playlist from this list">
|
||||
<Switch checked={false} size="sm" />
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Generate playlist</span>
|
||||
</label>
|
||||
<Badge tone="ok" style={{ alignSelf: "flex-start" }}>Saved</Badge>
|
||||
<div><Button variant="primary">Save changes</Button></div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraktLists() {
|
||||
const [editing, setEditing] = React.useState(null);
|
||||
const [addOpen, setAddOpen] = React.useState(false);
|
||||
const busy = false;
|
||||
|
||||
const editingRow = LISTS.find((l) => l.id === editing) || null;
|
||||
if (editingRow) {
|
||||
return <TraktListEditor row={editingRow} onBack={() => setEditing(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Trakt Lists</span>
|
||||
{busy && (
|
||||
<Badge tone="accent"><Spinner size={12} /> Busy</Badge>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={() => setAddOpen(true)}>Add Trakt list</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<Card padded={false}>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead><tr>
|
||||
<th style={th}>Slug</th>
|
||||
<th style={th}>Name</th>
|
||||
<th style={th}>Match status</th>
|
||||
<th style={{ ...th, textAlign: "right" }}></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{LISTS.map((row) => (
|
||||
<ListRow key={row.id} row={row} onOpen={() => setEditing(row.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span><span style={mono}>{LISTS.length}</span> lists</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9, padding: "10px 14px", borderRadius: "var(--radius-md)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="Search" s={14} color="var(--text-disabled)" />
|
||||
<span>"View matched items" opens the SPA search screen, filtered to this list's matched items.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* add dialog (inline mock, not a real Dialog overlay) */}
|
||||
{addOpen && (
|
||||
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.35)", zIndex: 10 }}>
|
||||
<div style={{ width: 480, background: "var(--surface-raised)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-lg)", padding: 20, display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Add Trakt list</span>
|
||||
<Input label="Trakt list URL" placeholder="https://trakt.tv/users/username/lists/list-name" fullWidth />
|
||||
<p style={{ margin: 0, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Fetching, saving and matching happen in the background — this dialog closes right away and the list
|
||||
appears once the server finishes.
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
|
||||
<Button variant="secondary" onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => setAddOpen(false)}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVTraktLists = TraktLists;
|
||||
})();
|
||||
@@ -0,0 +1,372 @@
|
||||
// Trash screen — media browser showing items with missing files, grouped by media type,
|
||||
// with bulk selection and delete controls. Grid layout with poster cards.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Spinner, Card } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
// Mock data: media type groups with items and counts
|
||||
const TRASH_DATA = {
|
||||
movies: {
|
||||
label: "Movies",
|
||||
totalCount: 12,
|
||||
items: [
|
||||
{ id: 1, title: "The Long Goodbye", mediaType: "Movie", hue: 120 },
|
||||
{ id: 2, title: "Blade Runner 2049", mediaType: "Movie", hue: 180 },
|
||||
{ id: 3, title: "Stalker", mediaType: "Movie", hue: 240 }
|
||||
]
|
||||
},
|
||||
shows: {
|
||||
label: "TV Shows",
|
||||
totalCount: 8,
|
||||
items: [
|
||||
{ id: 4, title: "The Wire", mediaType: "TelevisionShow", hue: 300 },
|
||||
{ id: 5, title: "Twin Peaks", mediaType: "TelevisionShow", hue: 30 },
|
||||
{ id: 6, title: "The Sopranos", mediaType: "TelevisionShow", hue: 60 },
|
||||
{ id: 7, title: "Mindhunter", mediaType: "TelevisionShow", hue: 90 }
|
||||
]
|
||||
},
|
||||
episodes: {
|
||||
label: "Episodes",
|
||||
totalCount: 34,
|
||||
items: [
|
||||
{ id: 8, title: "Ozymandias", mediaType: "Episode", hue: 150 },
|
||||
{ id: 9, title: "Half Measures", mediaType: "Episode", hue: 210 }
|
||||
]
|
||||
},
|
||||
images: {
|
||||
label: "Images",
|
||||
totalCount: 5,
|
||||
items: [
|
||||
{ id: 10, title: "Landscape Photo", mediaType: "Image", hue: 45 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Deterministic color based on hue
|
||||
function posterGradient(hue) {
|
||||
return `linear-gradient(160deg, hsl(${hue} 24% 17%), hsl(${hue} 26% 12%))`;
|
||||
}
|
||||
|
||||
// Icon type mapping
|
||||
const TYPE_ICON = {
|
||||
Movie: "Film",
|
||||
TelevisionShow: "Tv",
|
||||
TelevisionSeason: "BookOpen",
|
||||
Episode: "Play",
|
||||
Artist: "Music",
|
||||
MusicVideo: "Music",
|
||||
Song: "Music",
|
||||
OtherVideo: "Video",
|
||||
Image: "Image",
|
||||
RemoteStream: "Radio"
|
||||
};
|
||||
|
||||
function PosterCard({ item, selected, onToggle }) {
|
||||
const icon = TYPE_ICON[item.mediaType] || "Film";
|
||||
return (
|
||||
<div
|
||||
onClick={() => onToggle(item.id)}
|
||||
style={{
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
opacity: selected ? 1 : 0.95,
|
||||
transition: "opacity var(--dur-fast)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "2 / 3",
|
||||
background: posterGradient(item.hue),
|
||||
borderRadius: "var(--radius-md)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
border: selected ? "2px solid var(--ctv-accent)" : "1px solid var(--border-hairline)",
|
||||
boxShadow: selected ? "0 0 0 3px var(--ctv-accent-soft)" : "none",
|
||||
transition: "border var(--dur-fast), box-shadow var(--dur-fast)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `repeating-linear-gradient(135deg, hsl(${item.hue} 22% 10% / .55) 0 1px, transparent 1px 9px)`
|
||||
}}
|
||||
/>
|
||||
<Ico n={icon} s={24} color={`hsl(${item.hue} 30% 68%)`} />
|
||||
{selected && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
background: "var(--ctv-accent)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 14,
|
||||
fontWeight: "var(--weight-bold)"
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Title + meta */}
|
||||
<div style={{ marginTop: 8, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
font: "var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)",
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
title: item.title
|
||||
}}
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", marginTop: 2 }}>
|
||||
{item.mediaType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trash() {
|
||||
const [selected, setSelected] = React.useState(new Set());
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const totalCount = Object.values(TRASH_DATA).reduce((sum, g) => sum + g.totalCount, 0);
|
||||
const selectedCount = selected.size;
|
||||
|
||||
const toggleItem = (itemId) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId);
|
||||
} else {
|
||||
next.add(itemId);
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const all = new Set();
|
||||
Object.values(TRASH_DATA).forEach((group) => {
|
||||
group.items.forEach((item) => all.add(item.id));
|
||||
});
|
||||
setSelected(all);
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => setIsLoading(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Action bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
flex: "0 0 auto",
|
||||
minHeight: 58,
|
||||
background: "var(--surface-card)"
|
||||
}}
|
||||
>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
<span style={mono}>{selectedCount}</span> selected
|
||||
</span>
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<Button size="sm" variant="danger" startIcon={<Ico n="Trash2" s={14} />}>
|
||||
Delete
|
||||
</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" disabled={totalCount === 0} onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={14} />} onClick={handleRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" disabled={totalCount === 0} startIcon={<Ico n="Trash2" s={14} />}>
|
||||
Empty Trash
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{showError && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 20px",
|
||||
background: "var(--ctv-error-soft)",
|
||||
borderBottom: "1px solid var(--status-error)",
|
||||
color: "var(--status-error)"
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<Ico n="AlertTriangle" s={15} />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", flex: 1 }}>
|
||||
Failed to load trash items. Please try again.
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowError(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: "80px 20px",
|
||||
flex: 1
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<Spinner size={24} tone="accent" />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Loading trash…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && totalCount === 0 && (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "40px 20px" }}>
|
||||
<Card padded>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: "60px 20px",
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
<Ico n="CheckCircle2" s={32} color="var(--status-ok)" />
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-md)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
Trash is empty
|
||||
</div>
|
||||
<div style={{ font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-secondary)", marginTop: 4 }}>
|
||||
No missing files detected.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content — sections */}
|
||||
{!isLoading && totalCount > 0 && (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
{Object.entries(TRASH_DATA).map(([key, group]) => {
|
||||
if (group.totalCount === 0 || group.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hasMore = group.items.length < group.totalCount;
|
||||
|
||||
return (
|
||||
<section key={key} style={{ marginBottom: 40 }}>
|
||||
{/* Section header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
paddingBottom: 8,
|
||||
borderBottom: "1px solid var(--border-hairline)"
|
||||
}}
|
||||
>
|
||||
<h2 style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)", margin: 0 }}>
|
||||
{group.label}
|
||||
</h2>
|
||||
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>
|
||||
{group.totalCount} missing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Grid of cards */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
|
||||
gap: 16,
|
||||
marginBottom: hasMore ? 16 : 0
|
||||
}}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<PosterCard
|
||||
key={`${item.mediaType}-${item.id}`}
|
||||
item={item}
|
||||
selected={selected.has(item.id)}
|
||||
onToggle={toggleItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* "See all" button */}
|
||||
{hasMore && (
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 12 }}>
|
||||
<Button size="sm" variant="secondary">
|
||||
See all {group.totalCount} {group.label.toLowerCase()}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVTrash = Trash;
|
||||
})();
|
||||
@@ -0,0 +1,219 @@
|
||||
// Troubleshooting screen — tabbed view of system info and FFmpeg hardware acceleration capabilities.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Card, Tabs } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const generalJson = `{
|
||||
"appVersion": "26.4.0-fork",
|
||||
"osVersion": "Linux 6.8.0-1009-aws",
|
||||
"dotnetVersion": ".NET 10.0.0",
|
||||
"ffmpegVersion": "7.1.1",
|
||||
"ffmpegPath": "/usr/bin/ffmpeg",
|
||||
"processorCount": 8,
|
||||
"totalMemoryMb": 16384,
|
||||
"availableMemoryMb": 8642,
|
||||
"systemUptime": 847634,
|
||||
"ffmpegCapabilities": {
|
||||
"encoders": [
|
||||
"libx264",
|
||||
"libx265",
|
||||
"libvpx",
|
||||
"libvpx-vp9",
|
||||
"libopus",
|
||||
"aac"
|
||||
],
|
||||
"decoders": [
|
||||
"h264",
|
||||
"hevc",
|
||||
"vp9",
|
||||
"av1",
|
||||
"opus"
|
||||
],
|
||||
"hardwareAccel": [
|
||||
"vaapi",
|
||||
"qsv",
|
||||
"cuvid"
|
||||
]
|
||||
},
|
||||
"database": "SQLite",
|
||||
"configPath": "/config",
|
||||
"dataPath": "/data"
|
||||
}`;
|
||||
|
||||
const nvidiaCapabilities = `ffmpeg version 7.1.1 Copyright (c) 2000-2025 the FFmpeg developers
|
||||
|
||||
NVIDIA GPU acceleration detected:
|
||||
Device 0: NVIDIA GeForce RTX 4090
|
||||
Memory: 24576 MB
|
||||
CUDA Compute Capability: 8.9
|
||||
Encoding Support:
|
||||
H.264 (NVENC): ✓
|
||||
H.265 (NVENC): ✓
|
||||
AV1 (NVENC): ✓
|
||||
Decoding Support:
|
||||
H.264 (CUVID): ✓
|
||||
H.265 (CUVID): ✓
|
||||
VP9 (CUVID): ✓
|
||||
AV1 (CUVID): ✓
|
||||
|
||||
Detailed NVENC Capabilities (H.265):
|
||||
Supported Frame Rates: 24, 25, 30, 48, 50, 60 fps
|
||||
Max Resolution: 7680x4320
|
||||
Presets: default, slow, medium, fast
|
||||
B-Frames: Yes
|
||||
Reference Frames: Configurable`;
|
||||
|
||||
const qsvCapabilities = `Intel Quick Sync Video (QSV) Probe:
|
||||
|
||||
Device: Intel(R) Arc(TM) A770 Graphics
|
||||
Driver Version: 27.1.14
|
||||
Media SDK Version: 2024.1
|
||||
|
||||
Encoding Capabilities:
|
||||
H.264 (AVC):
|
||||
Profile: Baseline, Main, High
|
||||
Level: 4.2
|
||||
Rate Control: VBR, CBR, CQP
|
||||
Supported Resolutions: 176x144 to 4096x2304
|
||||
|
||||
HEVC (H.265):
|
||||
Profile: Main, Main10
|
||||
Level: 5.1
|
||||
Rate Control: VBR, CBR, CQP
|
||||
Supported Resolutions: 176x144 to 8192x4320
|
||||
|
||||
Decoding Capabilities:
|
||||
H.264: Yes
|
||||
HEVC: Yes
|
||||
VP9: Yes
|
||||
AV1: Partial (decode only, limited profiles)
|
||||
|
||||
Performance:
|
||||
Throughput: 8K@30fps simultaneous encode/decode
|
||||
Latency: <50ms`;
|
||||
|
||||
const vaapiCapabilities = `VAAPI Device Probe Results:
|
||||
|
||||
Device: /dev/dri/renderD128 (Intel)
|
||||
Driver: Intel iHD
|
||||
API Version: 1.15
|
||||
|
||||
Encoding Support:
|
||||
H.264: Yes (Main, High profile)
|
||||
HEVC: Yes (Main, Main10 profile)
|
||||
VP8: Yes
|
||||
VP9: Yes
|
||||
AV1: No
|
||||
|
||||
Decoding Support:
|
||||
H.264: Yes
|
||||
HEVC: Yes
|
||||
VP8: Yes
|
||||
VP9: Yes
|
||||
AV1: Yes (partial)
|
||||
MPEG2: Yes
|
||||
|
||||
Configuration:
|
||||
Max Resolution: 7680x4320
|
||||
Concurrent Sessions: Unlimited
|
||||
Performance Mode: Enabled`;
|
||||
|
||||
const tabs = [
|
||||
{ label: 'General', value: 'general' },
|
||||
{ label: 'NVIDIA', value: 'nvidia' },
|
||||
{ label: 'QSV', value: 'qsv' },
|
||||
{ label: 'VAAPI', value: 'vaapi' }
|
||||
];
|
||||
|
||||
const tabContents = {
|
||||
general: {
|
||||
title: 'General',
|
||||
subtitle: 'Version, environment, hardware and FFmpeg settings',
|
||||
content: generalJson
|
||||
},
|
||||
nvidia: {
|
||||
title: 'NVIDIA',
|
||||
subtitle: 'Raw capability probe output',
|
||||
content: nvidiaCapabilities
|
||||
},
|
||||
qsv: {
|
||||
title: 'QSV',
|
||||
subtitle: 'Raw capability probe output',
|
||||
content: qsvCapabilities
|
||||
},
|
||||
vaapi: {
|
||||
title: 'VAAPI',
|
||||
subtitle: 'Raw capability probe output',
|
||||
content: vaapiCapabilities
|
||||
}
|
||||
};
|
||||
|
||||
function CopyButton({ text }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const copy = () => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
onClick={copy}
|
||||
size="sm"
|
||||
startIcon={copied ? <Ico n="Check" s={14} /> : <Ico n="Copy" s={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function Troubleshooting() {
|
||||
const [activeTab, setActiveTab] = React.useState('general');
|
||||
|
||||
const activeContent = tabContents[activeTab];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Tabs tabs={tabs} value={activeTab} onChange={setActiveTab} />
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="RefreshCw" s={14} />} variant="secondary">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* content area */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px" }}>
|
||||
<Card
|
||||
title={activeContent.title}
|
||||
subtitle={activeContent.subtitle}
|
||||
actions={<CopyButton text={activeContent.content} />}
|
||||
>
|
||||
<pre style={{
|
||||
font: `var(--weight-normal) var(--text-xs)/1.4 var(--font-mono)`,
|
||||
color: "var(--text-primary)",
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
padding: "16px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
overflow: "auto",
|
||||
margin: 0,
|
||||
maxHeight: "calc(100vh - 280px)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordWrap: "break-word"
|
||||
}}>
|
||||
<code>{activeContent.content}</code>
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVTroubleshooting = Troubleshooting;
|
||||
})();
|
||||
@@ -0,0 +1,156 @@
|
||||
// Watermarks screen — list+detail: a flush-row list of saved watermark presets,
|
||||
// and a settings-row editor form (name/mode/image/placement/timing/opacity) with
|
||||
// its own action bar (Back · title · validation · Save). Mirrors Channels.jsx density.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Checkbox } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const WATERMARKS = [
|
||||
{ id: 1, name: "Channel Bug — Bottom Right", mode: "Permanent" },
|
||||
{ id: 2, name: "Classics Corner Logo", mode: "Permanent" },
|
||||
{ id: 3, name: "Late Night Bumper Mark", mode: "Intermittent" },
|
||||
{ id: 4, name: "Kids Block Watermark", mode: "OpacityExpression" },
|
||||
{ id: 5, name: "News Ticker Logo", mode: "Permanent" },
|
||||
{ id: 6, name: "Sports Overlay (fade)", mode: "OpacityExpression" },
|
||||
];
|
||||
|
||||
const MODE_OPTIONS = ["None", "Permanent", "Intermittent", "Opacity Expression"];
|
||||
const IMAGE_SOURCE_OPTIONS = ["Custom", "Channel Logo"];
|
||||
const LOCATION_OPTIONS = ["Bottom Right", "Bottom Middle", "Bottom Left", "Left Middle", "Top Left", "Top Middle", "Top Right", "Right Middle"];
|
||||
const SIZE_OPTIONS = ["Scaled", "Actual Size"];
|
||||
const FREQUENCY_OPTIONS = ["5 minutes", "10 minutes", "15 minutes", "20 minutes", "30 minutes", "60 minutes"];
|
||||
|
||||
const rowMain = { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 };
|
||||
const rowLabel = { font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" };
|
||||
const rowHelp = { font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-faint)" };
|
||||
|
||||
function Row({ label, help, control = 320, first = false, children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 24, padding: "13px 20px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<div style={rowMain}>
|
||||
<div style={rowLabel}>{label}</div>
|
||||
{help && <div style={rowHelp}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px`, minWidth: 0 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Watermarks() {
|
||||
const [screen, setScreen] = React.useState("list"); // "list" | "edit" | "add"
|
||||
const [active, setActive] = React.useState(WATERMARKS[0]);
|
||||
const [mode, setMode] = React.useState("Permanent");
|
||||
const [imageSource, setImageSource] = React.useState("Custom");
|
||||
const [size, setSize] = React.useState("Scaled");
|
||||
const [placeWithin, setPlaceWithin] = React.useState(false);
|
||||
const modeNone = mode === "None";
|
||||
|
||||
const openEdit = (wm) => { setActive(wm); setMode(wm.mode === "OpacityExpression" ? "Opacity Expression" : wm.mode); setScreen("edit"); };
|
||||
const openAdd = () => { setActive(null); setMode("Permanent"); setImageSource("Custom"); setSize("Scaled"); setPlaceWithin(false); setScreen("add"); };
|
||||
|
||||
if (screen === "list") {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{WATERMARKS.length} watermarks</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={openAdd}>New watermark</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{WATERMARKS.map((wm, i) => (
|
||||
<div key={wm.id} style={{ display: "flex", alignItems: "center", gap: 12, height: 48, padding: "0 16px", borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="Image" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" onClick={() => openEdit(wm)}
|
||||
style={{ flex: 1, minWidth: 0, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{wm.name}
|
||||
</button>
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-faint)" }}>{wm.mode}</span>
|
||||
<IconButton size="sm" variant="ghost" title={`Copy ${wm.name}`}><Ico n="Copy" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEdit = screen === "edit";
|
||||
const title = isEdit ? "Edit watermark" : "New watermark";
|
||||
const name = isEdit ? active.name : "";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setScreen("list")}>All watermarks</Button>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>{isEdit ? "Save watermark" : "Add watermark"}</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false} title="Watermark">
|
||||
<Row label="Name" control={360} first>
|
||||
<Input size="sm" value={name} placeholder="Watermark name" fullWidth />
|
||||
</Row>
|
||||
<Row label="Mode" control={360}>
|
||||
<Select value={mode} onChange={(e) => setMode(e.target.value)} options={MODE_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Image source" control={360}>
|
||||
<Select disabled={modeNone} value={imageSource} onChange={(e) => setImageSource(e.target.value)} options={IMAGE_SOURCE_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Image" help="Only used with a custom image source." control={360}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" disabled={imageSource !== "Custom"} startIcon={<Ico n="Upload" s={14} />}>Upload image</Button>
|
||||
{imageSource === "Custom" && (
|
||||
<div style={{ width: 140, height: 52, borderRadius: "var(--radius-sm)", border: "1px dashed var(--border-control)", background: "var(--ctv-surface-2)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Ico n="Image" s={20} color="var(--text-faint)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Location" control={360}>
|
||||
<Select disabled={modeNone} value="Bottom Right" options={LOCATION_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Place within source content" control={200}>
|
||||
<Checkbox checked={placeWithin} onChange={setPlaceWithin} disabled={modeNone} />
|
||||
</Row>
|
||||
<Row label="Size" control={360}>
|
||||
<Select disabled={modeNone} value={size} onChange={(e) => setSize(e.target.value)} options={SIZE_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Width" control={220}>
|
||||
<Input size="sm" type="number" disabled={modeNone || size === "Actual Size"} value="15" trailing="%" fullWidth />
|
||||
</Row>
|
||||
<Row label="Horizontal margin" control={220}>
|
||||
<Input size="sm" type="number" disabled={modeNone} value="5" trailing="%" fullWidth />
|
||||
</Row>
|
||||
<Row label="Vertical margin" control={220}>
|
||||
<Input size="sm" type="number" disabled={modeNone} value="5" trailing="%" fullWidth />
|
||||
</Row>
|
||||
<Row label="Frequency" control={360}>
|
||||
<Select disabled={mode !== "Intermittent"} value="15 minutes" options={FREQUENCY_OPTIONS} fullWidth />
|
||||
</Row>
|
||||
<Row label="Duration" control={220}>
|
||||
<Input size="sm" type="number" disabled={mode !== "Intermittent"} value="15" trailing="seconds" fullWidth />
|
||||
</Row>
|
||||
<Row label="Opacity" control={220}>
|
||||
<Input size="sm" type="number" disabled={modeNone || mode === "Opacity Expression"} value="100" trailing="%" fullWidth />
|
||||
</Row>
|
||||
<Row label="Opacity expression" control={360}>
|
||||
<Input size="sm" disabled={mode !== "Opacity Expression"} value={mode === "Opacity Expression" ? "0.5 + 0.5 * sin(t)" : ""} placeholder="Expression" fullWidth />
|
||||
</Row>
|
||||
<Row label="Z-index" control={220}>
|
||||
<Input size="sm" type="number" value="0" fullWidth />
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVWatermarks = Watermarks;
|
||||
})();
|
||||
@@ -0,0 +1,174 @@
|
||||
// YamlValidator screen — sequential-schedule YAML validation with schema
|
||||
// selection and JSON output preview.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, Card, Switch } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const mockYaml = `content:
|
||||
- movie: "/path/to/movie1.mkv"
|
||||
- collection: "80s Hits"
|
||||
- movie: "/path/to/movie2.mkv"
|
||||
- show: "Newsroom"
|
||||
episode: 1
|
||||
season: 1`;
|
||||
|
||||
const mockMessages = [
|
||||
"Show 'Newsroom' not found in library — skipping",
|
||||
"Collection '80s Hits' is empty",
|
||||
];
|
||||
|
||||
const mockJson = `{
|
||||
"content": [
|
||||
{
|
||||
"movie": "/path/to/movie1.mkv",
|
||||
"startTime": "00:00:00"
|
||||
},
|
||||
{
|
||||
"collection": "80s Hits",
|
||||
"startTime": "02:15:30"
|
||||
},
|
||||
{
|
||||
"movie": "/path/to/movie2.mkv",
|
||||
"startTime": "02:15:30"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
function YamlValidator() {
|
||||
const [isImport, setIsImport] = React.useState(false);
|
||||
const [showResult, setShowResult] = React.useState(true);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
|
||||
<div style={{ padding: "16px 20px" }}>
|
||||
{/* Input Card */}
|
||||
<Card
|
||||
padded
|
||||
title="Sequential Schedule Validation"
|
||||
subtitle="Paste a sequential-schedule YAML document to validate it against the schema"
|
||||
>
|
||||
<label style={{ display: "block", marginBottom: 16 }}>
|
||||
<span style={{
|
||||
display: "block",
|
||||
marginBottom: 8,
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--text-primary)"
|
||||
}}>
|
||||
YAML
|
||||
</span>
|
||||
<textarea
|
||||
value={mockYaml}
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
rows={16}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px 16px",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "var(--text-sm)",
|
||||
lineHeight: 1.5,
|
||||
color: "var(--text-primary)",
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
border: "1px solid var(--border-control)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
resize: "vertical",
|
||||
minHeight: 240
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginTop: 16
|
||||
}}>
|
||||
<Switch
|
||||
checked={isImport}
|
||||
onChange={setIsImport}
|
||||
label="Import schema"
|
||||
/>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button
|
||||
variant="primary"
|
||||
startIcon={<Ico n="ListChecks" s={14} />}
|
||||
onClick={() => setShowResult(!showResult)}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{showResult && (
|
||||
<div style={{ padding: "0 20px 16px 20px" }}>
|
||||
{/* Messages Card */}
|
||||
<Card
|
||||
padded
|
||||
title={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<Ico n="TriangleAlert" s={16} color="var(--status-error)" />
|
||||
{mockMessages.length} validation message{mockMessages.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<ul style={{
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8
|
||||
}}>
|
||||
{mockMessages.map((msg, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
font: "var(--text-sm)/1.5 var(--font-sans)",
|
||||
color: "var(--status-error)"
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{/* JSON Output Card */}
|
||||
<Card
|
||||
padded
|
||||
title="JSON"
|
||||
subtitle="YAML converted to JSON"
|
||||
>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: "12px 16px",
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
border: "1px solid var(--border-control)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
overflow: "auto",
|
||||
maxHeight: 300
|
||||
}}>
|
||||
<code style={{
|
||||
font: "var(--text-sm)/1.6 var(--font-mono)",
|
||||
color: "var(--text-secondary)",
|
||||
whiteSpace: "pre"
|
||||
}}>
|
||||
{mockJson}
|
||||
</code>
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVYamlValidator = YamlValidator;
|
||||
})();
|
||||
@@ -76,24 +76,61 @@
|
||||
})();
|
||||
</script>
|
||||
<script src="data.js"></script>
|
||||
<script src="screens.js"></script>
|
||||
<script type="text/babel" src="icons.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Shell.jsx" data-presets="react"></script>
|
||||
<!-- Screen prototypes (order mirrors screens.js / web/src/app/routes.tsx) -->
|
||||
<script type="text/babel" src="Dashboard.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Channels.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="ChannelBuilder.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="AutoTune.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="ScheduleEditor.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Epg.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Guide.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Schedules.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Blocks.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Templates.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Decos.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="DecoTemplates.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Playouts.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="ScheduleLibrary.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Media.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Search.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Trash.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Collections.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="MultiCollections.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="RerunCollections.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Playlists.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="FillerPresets.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Libraries.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="TraktLists.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Settings.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="ApiKey.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Logs.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Troubleshooting.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="BlockPlayoutTroubleshooting.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="YamlValidator.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="FFmpegProfiles.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Watermarks.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="ChannelEdit.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="MediaDetail.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="LocalLibraryEdit.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="PlexSource.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="RemoteSource.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="RemoteConnectionEdit.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="RemoteLibrariesEdit.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="PathReplacementsEdit.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="ImageBrowser.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="PlayoutScheduleEditors.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="PlaybackTroubleshooting.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Login.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" src="Setup.jsx" data-presets="react"></script>
|
||||
<script type="text/babel" data-presets="react">
|
||||
const { Sidebar, TopBar } = window.CTVShell;
|
||||
const SCREENS = window.CTV_SCREENS;
|
||||
const TITLES = Object.fromEntries(SCREENS.map((s) => [s.id, s.title]));
|
||||
|
||||
const TITLES = {
|
||||
dashboard: "Dashboard", channels: "Channels", builder: "New Channel", autoTune: "Auto-Tune", guide: "Guide", schedules: "Schedules",
|
||||
playouts: "Playouts", collections: "Collections", libraries: "Libraries", settings: "Settings",
|
||||
};
|
||||
function screenComp(id) {
|
||||
const s = SCREENS.find((x) => x.id === id);
|
||||
return s ? window["CTV" + s.exp] : null;
|
||||
}
|
||||
|
||||
function Placeholder({ label }) {
|
||||
const Ico = window.Ico;
|
||||
@@ -105,27 +142,27 @@ function Placeholder({ label }) {
|
||||
);
|
||||
}
|
||||
|
||||
function readHashView() {
|
||||
const h = (window.location.hash || "").replace(/^#/, "");
|
||||
return SCREENS.some((s) => s.id === h) ? h : null;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [view, setView] = React.useState("dashboard");
|
||||
const screens = {
|
||||
dashboard: window.CTVDashboard,
|
||||
channels: window.CTVChannels,
|
||||
builder: window.CTVChannelBuilder,
|
||||
autoTune: window.CTVAutoTune,
|
||||
guide: window.CTVEpg,
|
||||
schedules: window.CTVScheduleEditor,
|
||||
playouts: window.CTVPlayouts,
|
||||
libraries: window.CTVLibraries,
|
||||
settings: window.CTVSettings,
|
||||
};
|
||||
const Screen = screens[view];
|
||||
const [view, setView] = React.useState(readHashView() || "dashboard");
|
||||
React.useEffect(() => {
|
||||
const onHash = () => { const h = readHashView(); if (h) setView(h); };
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
const nav = (id) => { setView(id); try { window.history.replaceState(null, "", "#" + id); } catch (e) {} };
|
||||
const Screen = screenComp(view);
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%" }}>
|
||||
<Sidebar view={view} setView={setView} />
|
||||
<Sidebar view={view} setView={nav} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<TopBar title={TITLES[view]} />
|
||||
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{Screen ? <Screen /> : <Placeholder label={TITLES[view]} />}
|
||||
{Screen ? <Screen /> : <Placeholder label={TITLES[view] || view} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,11 +170,12 @@ function App() {
|
||||
}
|
||||
|
||||
function mount() {
|
||||
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
|
||||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVAutoTune || !window.CTVScheduleEditor || !window.CTVEpg ||
|
||||
!window.CTVPlayouts || !window.CTVLibraries || !window.CTVSettings) {
|
||||
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTV_SCREENS) {
|
||||
return setTimeout(mount, 40);
|
||||
}
|
||||
// Wait until every screen prototype global is defined (each <Screen>.jsx assigns window.CTV<exp>).
|
||||
const missing = window.CTV_SCREENS.filter((s) => !window["CTV" + s.exp]);
|
||||
if (missing.length) return setTimeout(mount, 40);
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||
}
|
||||
mount();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Canonical screen inventory for the ChicoryTV admin template — single source of truth for BOTH
|
||||
// the sidebar nav (Shell.jsx) and the view/screen map (app.html), so the two can never drift.
|
||||
// Mirrors web/src/app/routes.tsx: the first three groups (Primary/Media/System) match the shipped
|
||||
// sidebar; "Editors & auth" is a design-kit-only section exposing sub-editors that prod reaches via
|
||||
// sub-paths (edit-channel, library source editors, login/setup), so every screen is viewable here.
|
||||
// exp = the global each <Screen>.jsx assigns (window.CTV<exp>). icon = a lucide (PascalCase) name.
|
||||
(function () {
|
||||
window.CTV_NAV_GROUPS = [
|
||||
{ key: "primary", label: null },
|
||||
{ key: "media", label: "Media" },
|
||||
{ key: "system", label: "System" },
|
||||
{ key: "editors", label: "Editors & auth" },
|
||||
];
|
||||
|
||||
window.CTV_SCREENS = [
|
||||
// Primary
|
||||
{ id: "dashboard", exp: "Dashboard", title: "Dashboard", icon: "LayoutDashboard", group: "primary" },
|
||||
{ id: "channels", exp: "Channels", title: "Channels", icon: "Tv", group: "primary" },
|
||||
{ id: "builder", exp: "ChannelBuilder", title: "New Channel", icon: "Plus", group: "primary" },
|
||||
{ id: "autoTune", exp: "AutoTune", title: "Auto-Tune", icon: "Sparkles", group: "primary" },
|
||||
{ id: "guide", exp: "Guide", title: "Guide", icon: "LayoutGrid", group: "primary" },
|
||||
{ id: "schedules", exp: "Schedules", title: "Schedules", icon: "CalendarClock", group: "primary" },
|
||||
{ id: "blocks", exp: "Blocks", title: "Blocks", icon: "Boxes", group: "primary" },
|
||||
{ id: "templates", exp: "Templates", title: "Templates", icon: "CalendarDays", group: "primary" },
|
||||
{ id: "decos", exp: "Decos", title: "Decos", icon: "Palette", group: "primary" },
|
||||
{ id: "decoTemplates", exp: "DecoTemplates", title: "Deco Templates", icon: "CalendarRange", group: "primary" },
|
||||
{ id: "playouts", exp: "Playouts", title: "Playouts", icon: "ListVideo", group: "primary" },
|
||||
// Media
|
||||
{ id: "media", exp: "Media", title: "Browse", icon: "Clapperboard", group: "media" },
|
||||
{ id: "search", exp: "Search", title: "Search", icon: "Search", group: "media" },
|
||||
{ id: "trash", exp: "Trash", title: "Trash", icon: "Trash2", group: "media" },
|
||||
{ id: "collections", exp: "Collections", title: "Collections", icon: "FolderTree", group: "media" },
|
||||
{ id: "multiCollections", exp: "MultiCollections", title: "Multi-Collections", icon: "Layers", group: "media" },
|
||||
{ id: "rerunCollections", exp: "RerunCollections", title: "Rerun Collections", icon: "Repeat", group: "media" },
|
||||
{ id: "playlists", exp: "Playlists", title: "Playlists", icon: "ListMusic", group: "media" },
|
||||
{ id: "fillerPresets", exp: "FillerPresets", title: "Filler Presets", icon: "Film", group: "media" },
|
||||
{ id: "libraries", exp: "Libraries", title: "Libraries", icon: "Library", group: "media" },
|
||||
{ id: "traktLists", exp: "TraktLists", title: "Trakt Lists", icon: "Link2", group: "media" },
|
||||
// System
|
||||
{ id: "settings", exp: "Settings", title: "Settings", icon: "Settings", group: "system" },
|
||||
{ id: "apiKey", exp: "ApiKey", title: "API Key", icon: "KeyRound", group: "system" },
|
||||
{ id: "logs", exp: "Logs", title: "Logs", icon: "ScrollText", group: "system" },
|
||||
{ id: "troubleshooting", exp: "Troubleshooting", title: "Troubleshooting", icon: "Stethoscope", group: "system" },
|
||||
{ id: "blockPlayoutTroubleshooting", exp: "BlockPlayoutTroubleshooting", title: "Block History", icon: "Boxes", group: "system" },
|
||||
{ id: "yamlValidator", exp: "YamlValidator", title: "Schedule Validator", icon: "ListChecks", group: "system" },
|
||||
{ id: "ffmpegProfiles", exp: "FFmpegProfiles", title: "FFmpeg Profiles", icon: "SlidersHorizontal", group: "system" },
|
||||
{ id: "watermarks", exp: "Watermarks", title: "Watermarks", icon: "Stamp", group: "system" },
|
||||
// Editors & auth (design-kit-only section)
|
||||
{ id: "editChannel", exp: "ChannelEdit", title: "Edit Channel", icon: "Pencil", group: "editors" },
|
||||
{ id: "mediaDetail", exp: "MediaDetail", title: "Media Detail", icon: "Film", group: "editors" },
|
||||
{ id: "localLibraryEdit", exp: "LocalLibraryEdit", title: "Local Library", icon: "Library", group: "editors" },
|
||||
{ id: "plexSource", exp: "PlexSource", title: "Plex Source", icon: "Server", group: "editors" },
|
||||
{ id: "remoteSource", exp: "RemoteSource", title: "Jellyfin / Emby Source", icon: "Server", group: "editors" },
|
||||
{ id: "remoteConnectionEdit", exp: "RemoteConnectionEdit", title: "Remote Connection", icon: "Link2", group: "editors" },
|
||||
{ id: "remoteLibrariesEdit", exp: "RemoteLibrariesEdit", title: "Remote Libraries", icon: "Library", group: "editors" },
|
||||
{ id: "pathReplacementsEdit", exp: "PathReplacementsEdit", title: "Path Replacements", icon: "Replace", group: "editors" },
|
||||
{ id: "imageBrowser", exp: "ImageBrowser", title: "Image Browser", icon: "Image", group: "editors" },
|
||||
{ id: "playoutScheduleEditors", exp: "PlayoutScheduleEditors", title: "Playout Schedules", icon: "CalendarClock", group: "editors" },
|
||||
{ id: "playbackTroubleshooting", exp: "PlaybackTroubleshooting", title: "Playback Troubleshooting", icon: "Stethoscope", group: "editors" },
|
||||
{ id: "login", exp: "Login", title: "Login", icon: "LogIn", group: "editors" },
|
||||
{ id: "setup", exp: "Setup", title: "First-Run Setup", icon: "Wand2", group: "editors" },
|
||||
];
|
||||
})();
|
||||
@@ -39,6 +39,34 @@ First exercised end-to-end by the Settings screen (#93, PR #138).
|
||||
7. **Commit `design-system/` changes with the feature branch** so repo and Claude Design stay
|
||||
mirrored (both sides updated in the same session).
|
||||
|
||||
## Bulk mirror sweep (#388) — the whole-inventory pass
|
||||
|
||||
The one-off #388 sweep rebuilt/added a prototype for every shipped screen at once (mockups only —
|
||||
faithful visual/layout parity, no logic). Two structural pieces landed to keep it maintainable:
|
||||
|
||||
- **`templates/chicorytv-admin/screens.js`** is now the single source of truth for the screen
|
||||
inventory: `window.CTV_SCREENS = [{ id, exp, title, icon, group }, …]` (+ `window.CTV_NAV_GROUPS`).
|
||||
`Shell.jsx` builds the sidebar nav from it and `app.html` builds the view→component map + titles
|
||||
from it, so the nav and the screen map can't drift. The first three groups (Primary/Media/System)
|
||||
mirror `web/src/app/routes.tsx`; the `editors` group is a design-kit-only section exposing the
|
||||
sub-editors prod reaches via sub-paths (edit-channel, source editors, login/setup).
|
||||
- **`app.html` is hash-routed**: `#<id>` deep-links a view (used by the headless render-check), and
|
||||
the mount guard waits until every `window.CTV<exp>` is defined before rendering.
|
||||
- Adding a screen later = add one `<Screen>.jsx`, one row in `screens.js`, and one `<script>` tag in
|
||||
`app.html`. No handoff bundle is required for a mockup (write one only where a screen needs an
|
||||
implementation spec).
|
||||
|
||||
## Automated reminder hook (keeps the mirror from drifting)
|
||||
|
||||
`.claude/hooks/design-sync-reminder.sh` (wired in `.claude/settings.json`) nudges — never blocks — a
|
||||
Claude Code session to keep this mirror current, keyed purely on the UI fileset `web/src/**.{tsx,css}`
|
||||
(tests excluded; widen `UI_RE` in the script if the surface grows). Both directions, throttled once
|
||||
per session, fail-open:
|
||||
- **PreToolUse / Write|Edit** — first UI-file edit of the session → *pull-first* reminder.
|
||||
- **Stop** — if the turn actually changed a UI file → one-shot *mirror/push-back* reminder.
|
||||
|
||||
It can only remind: the `DesignSync` push itself is main-session-only and needs your claude.ai approval.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `finalize_plan` requires an explicit `deletes: []` even when deleting nothing.
|
||||
|
||||
Reference in New Issue
Block a user