feat(69): Auto-Tune SPA wizard (Configure → Preview → Create) — PR2 #389
@@ -0,0 +1,900 @@
|
||||
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
|
||||
// The "automatic-first" creation mode alongside the manual Channel Builder.
|
||||
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
|
||||
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
|
||||
// Additive & non-destructive: never edits or deletes an existing channel; number
|
||||
// or name collisions are skipped, never overwritten. Each generated channel is
|
||||
// backed by a live SmartCollection query so it keeps tracking the library.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
|
||||
|
||||
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
|
||||
// nameOf() and order are display-only here; the real server owns query + name.
|
||||
const AXES = [
|
||||
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
|
||||
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
|
||||
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
|
||||
];
|
||||
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
|
||||
|
||||
// ---- Mock library metadata (EF distinct+count in the real app) ------------
|
||||
const LIBRARY = {
|
||||
TvShow: [
|
||||
{ value: "The Office", count: 201 },
|
||||
{ value: "Friends", count: 236 },
|
||||
{ value: "Breaking Bad", count: 62 },
|
||||
{ value: "Parks and Recreation", count: 125 },
|
||||
{ value: "The Twilight Zone", count: 156 },
|
||||
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
TvGenre: [
|
||||
{ value: "Comedy", count: 640 },
|
||||
{ value: "Drama", count: 512 },
|
||||
{ value: "Sci-Fi", count: 208 },
|
||||
{ value: "Crime", count: 174 },
|
||||
],
|
||||
MovieGenre: [
|
||||
{ value: "Action", count: 42 },
|
||||
{ value: "Sci-Fi", count: 28 },
|
||||
{ value: "Horror", count: 35 },
|
||||
{ value: "Comedy", count: 51 },
|
||||
{ value: "Noir", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
};
|
||||
|
||||
// Coexistence demo: some names already exist (deduped, unchecked by default)
|
||||
// and some numbers are already taken (allocation skips them).
|
||||
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
|
||||
const TAKEN_NUMBERS = new Set(["500", "503"]);
|
||||
|
||||
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
|
||||
|
||||
// ---- Preview computation (advisory numbers, re-validated at create) -------
|
||||
function buildProposals(axisIds, minItems, startingNumber) {
|
||||
const out = [];
|
||||
let next = Math.max(1, startingNumber | 0);
|
||||
const takenThisRun = new Set(TAKEN_NUMBERS);
|
||||
const alloc = () => {
|
||||
while (takenThisRun.has(String(next))) next++;
|
||||
const n = String(next);
|
||||
takenThisRun.add(n);
|
||||
next++;
|
||||
return n;
|
||||
};
|
||||
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
|
||||
AXES.forEach((ax) => {
|
||||
if (!axisIds.has(ax.id)) return;
|
||||
LIBRARY[ax.id]
|
||||
.filter((row) => row.count >= minItems)
|
||||
.slice()
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
.forEach((row) => {
|
||||
const name = ax.nameOf(row.value);
|
||||
const exists = EXISTING_NAMES.has(name);
|
||||
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
|
||||
// explicit initials + color (ChannelLogo only derives them from name). --
|
||||
const BUG_PALETTE = [
|
||||
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
|
||||
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
|
||||
];
|
||||
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
|
||||
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
|
||||
function Bug({ name, initials, ci, size = 32 }) {
|
||||
const idx = ci != null ? ci : bugHash(name);
|
||||
const [fg, bg] = BUG_PALETTE[idx];
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example content sources (advisory sample of the smart-collection query)
|
||||
const GENRE_SHOWS = {
|
||||
Comedy: ["The Office", "Friends", "Parks and Recreation"],
|
||||
Drama: ["Breaking Bad", "The Twilight Zone"],
|
||||
"Sci-Fi": ["The Twilight Zone", "Firefly"],
|
||||
Crime: ["Breaking Bad"],
|
||||
};
|
||||
const MOVIE_TITLES = {
|
||||
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
|
||||
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
|
||||
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
|
||||
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
|
||||
Noir: ["Rain on 5th", "The Long Con"],
|
||||
};
|
||||
const isGenreAxis = (axis) => axis !== "TvShow";
|
||||
function baseSources(p) {
|
||||
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
|
||||
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
|
||||
return []; // TvShow channels track a single show — no per-source editor
|
||||
}
|
||||
function effectiveSources(p, ov) {
|
||||
const ex = ov.exclude || [];
|
||||
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
|
||||
}
|
||||
// Advisory item-count estimate as sources are added/removed (per-source share).
|
||||
function estItems(p, ov) {
|
||||
const base = baseSources(p);
|
||||
if (!base.length) return p.itemCount;
|
||||
const share = p.itemCount / base.length;
|
||||
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
|
||||
}
|
||||
// Sources with their rotation weight (episodes played per rotation).
|
||||
function sourceList(p, ov) {
|
||||
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
|
||||
}
|
||||
|
||||
// Compact rotation-weight stepper (− N +).
|
||||
function Weight({ w, onChange }) {
|
||||
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
|
||||
return (
|
||||
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
|
||||
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
|
||||
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
|
||||
// Effective order for a channel: the axis default, unless overridden per channel.
|
||||
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
|
||||
|
||||
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
|
||||
function buildSchedule(p, ov) {
|
||||
const mins = RUNTIME[p.axis] || 30;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
let seq;
|
||||
if (p.axis === "TvShow") {
|
||||
const n = Math.min(8, Math.max(3, p.itemCount));
|
||||
let eps = Array.from({ length: n }, (_, i) => i + 1);
|
||||
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
|
||||
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
|
||||
} else {
|
||||
const list = sourceList(p, ov).filter((s) => s.weight > 0);
|
||||
if (!list.length) return [];
|
||||
if (shuffled) {
|
||||
// Weighted round-robin interleave — honors the per-source rotation ratio.
|
||||
const total = list.reduce((a, s) => a + s.weight, 0);
|
||||
const st = list.map((s) => ({ ...s, acc: 0 }));
|
||||
const count = Math.min(9, Math.max(5, total * 2));
|
||||
seq = Array.from({ length: count }, () => {
|
||||
let pick = null;
|
||||
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
|
||||
pick.acc -= total;
|
||||
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
|
||||
});
|
||||
} else {
|
||||
// Sequential — each source plays its rotation count in turn.
|
||||
seq = [];
|
||||
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
|
||||
seq = seq.slice(0, 9);
|
||||
}
|
||||
}
|
||||
let t = 20 * 60;
|
||||
return seq.map((b) => {
|
||||
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
|
||||
t += mins;
|
||||
return { ...b, start, mins };
|
||||
});
|
||||
}
|
||||
|
||||
function MiniEpg({ blocks }) {
|
||||
if (!blocks.length) return (
|
||||
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches — nothing to schedule.</div>
|
||||
);
|
||||
const PPM = 2.0;
|
||||
return (
|
||||
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
|
||||
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
|
||||
{blocks.map((b, i) => (
|
||||
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
|
||||
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
|
||||
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value, first }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
|
||||
const CH_TEMPLATES = [
|
||||
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
|
||||
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
|
||||
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
|
||||
];
|
||||
const ADV_GROUPS = [
|
||||
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
|
||||
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
|
||||
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
|
||||
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
|
||||
];
|
||||
|
||||
// Friendly toggle row (label + description + Switch), with an override tag.
|
||||
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
|
||||
return (
|
||||
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
|
||||
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
|
||||
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel Template picker (collapsed row + dropdown + spec chips).
|
||||
function TemplatePicker({ value, onPick }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
|
||||
</div>
|
||||
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{CH_TEMPLATES.map((x) => {
|
||||
const on = x.id === value;
|
||||
return (
|
||||
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
|
||||
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
{t.sets.map((s) => (
|
||||
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Advanced (~24-field) override disclosure: View defaults / Override.
|
||||
function AdvancedSettings({ ov, patch, templateName }) {
|
||||
const [mode, setMode] = React.useState("closed"); // closed | view | override
|
||||
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
|
||||
const vals = { ...defaults, ...(ov.adv || {}) };
|
||||
const override = mode === "override";
|
||||
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||||
const overridden = Object.keys(ov.adv || {}).length;
|
||||
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
|
||||
</div>
|
||||
{mode !== "closed" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
|
||||
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
|
||||
</div>
|
||||
{ADV_GROUPS.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k, v]) => override ? (
|
||||
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
|
||||
function LogoDrop({ name, src, onSet }) {
|
||||
const [over, setOver] = React.useState(false);
|
||||
const fileRef = React.useRef(null);
|
||||
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
|
||||
return (
|
||||
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
|
||||
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
|
||||
</div>
|
||||
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Channel detail slide-over: full per-channel settings -----------------
|
||||
function DetailPanel({ p, ov, patch, onClose }) {
|
||||
const [addText, setAddText] = React.useState("");
|
||||
const name = ov.customName != null ? ov.customName : p.name;
|
||||
const bug = ov.bug || {};
|
||||
const ax = AXIS[p.axis];
|
||||
const number = ov.number != null ? ov.number : p.number;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
const always = ov.always != null ? ov.always : true;
|
||||
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
|
||||
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
|
||||
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
|
||||
const avatar = (size) => bug.src
|
||||
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
|
||||
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
|
||||
const genre = isGenreAxis(p.axis);
|
||||
const base = baseSources(p);
|
||||
const ex = ov.exclude || [];
|
||||
const inc = ov.include || [];
|
||||
const kept = base.filter((s) => !ex.includes(s));
|
||||
const items = estItems(p, ov);
|
||||
const schedule = buildSchedule(p, ov);
|
||||
const ratios = ov.ratios || {};
|
||||
const wOf = (s) => ratios[s] || 1;
|
||||
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
|
||||
const list = [...kept, ...inc];
|
||||
const multi = list.length > 1;
|
||||
|
||||
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
|
||||
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
|
||||
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
|
||||
const addInc = () => {
|
||||
const v = addText.trim();
|
||||
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
|
||||
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
|
||||
setAddText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
|
||||
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
|
||||
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
{avatar(34)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
|
||||
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Channel identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
|
||||
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
|
||||
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</label>
|
||||
</div>
|
||||
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
|
||||
{avatar(48)}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
|
||||
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
{BUG_PALETTE.map((pair, i) => {
|
||||
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
|
||||
return (
|
||||
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
|
||||
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
|
||||
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={eyebrow}>Playback</div>
|
||||
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
|
||||
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Channel Template</div>
|
||||
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Query & size</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
|
||||
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
|
||||
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
|
||||
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
|
||||
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{genre && (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Content sources</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.</span>
|
||||
</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
{list.length === 0 && (
|
||||
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources — add one below.</div>
|
||||
)}
|
||||
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
|
||||
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
|
||||
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
|
||||
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
|
||||
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
|
||||
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{multi && (
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
|
||||
</div>
|
||||
{ex.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
|
||||
{ex.map((s) => (
|
||||
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
|
||||
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Example schedule</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 — the built playout may differ.</span>
|
||||
</div>
|
||||
<MiniEpg blocks={schedule} />
|
||||
</section>
|
||||
|
||||
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
function AutoTune() {
|
||||
const [step, setStep] = React.useState("configure"); // configure | preview | create
|
||||
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
|
||||
const [minItems, setMinItems] = React.useState("5");
|
||||
const [startingNumber, setStartingNumber] = React.useState("500");
|
||||
const [group, setGroup] = React.useState("Auto-Tuned");
|
||||
const [template, setTemplate] = React.useState("Standard");
|
||||
|
||||
const [proposals, setProposals] = React.useState([]);
|
||||
const [selected, setSelected] = React.useState(() => new Set());
|
||||
const [results, setResults] = React.useState(null);
|
||||
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
|
||||
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
|
||||
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
|
||||
|
||||
const patchOv = (name) => (patch) => setOverrides((o) => {
|
||||
const cur = o[name] || {};
|
||||
const delta = typeof patch === "function" ? patch(cur) : patch;
|
||||
return { ...o, [name]: { ...cur, ...delta } };
|
||||
});
|
||||
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
|
||||
|
||||
const toggleAxis = (id) =>
|
||||
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
|
||||
const runPreview = () => {
|
||||
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
|
||||
setProposals(p);
|
||||
// Default selection: everything that isn't an already-existing name.
|
||||
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
|
||||
setStep("preview");
|
||||
};
|
||||
|
||||
const runCreate = () => {
|
||||
// Demo the three outcome states across the selected set.
|
||||
const chosen = proposals.filter((p) => selected.has(p.name));
|
||||
const res = chosen.map((p, i) => {
|
||||
const nm = dispName(p);
|
||||
if (i === chosen.length - 1 && chosen.length > 2)
|
||||
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
|
||||
if (p.name === "Sci-Fi Movies")
|
||||
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
|
||||
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
|
||||
});
|
||||
setResults(res);
|
||||
setStep("create");
|
||||
};
|
||||
|
||||
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
||||
<Toolbar
|
||||
step={step}
|
||||
axisCount={axisIds.size}
|
||||
selectedCount={selected.size}
|
||||
onPreview={runPreview}
|
||||
onCreate={runCreate}
|
||||
onBack={() => setStep("configure")}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{step === "configure" && (
|
||||
<Configure
|
||||
axisIds={axisIds} toggleAxis={toggleAxis}
|
||||
minItems={minItems} setMinItems={setMinItems}
|
||||
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
|
||||
group={group} setGroup={setGroup}
|
||||
template={template} setTemplate={setTemplate}
|
||||
/>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
|
||||
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
|
||||
openDetail={setDetail} dispName={dispName} />
|
||||
)}
|
||||
{step === "create" && <Results results={results} group={group} />}
|
||||
</main>
|
||||
{detail != null && (() => {
|
||||
const p = proposals.find((x) => x.name === detail);
|
||||
if (!p) return null;
|
||||
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Toolbar with step rail + contextual primary action -------------------
|
||||
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, 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="Sparkles" s={19} /></span>
|
||||
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
<StepRail step={step} />
|
||||
</div>
|
||||
{step === "configure" && (
|
||||
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
|
||||
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<React.Fragment>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
|
||||
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
|
||||
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
|
||||
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{step === "create" && (
|
||||
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ id: "configure", label: "Configure" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "create", label: "Create" },
|
||||
];
|
||||
function StepRail({ step }) {
|
||||
const idx = STEPS.findIndex((s) => s.id === step);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < idx, active = i === idx;
|
||||
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
|
||||
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
|
||||
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
|
||||
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
|
||||
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
|
||||
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 1: Configure ----------------------------------------------------
|
||||
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
|
||||
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
|
||||
preview the proposed channels, then create the ones you want. Existing channels are never touched.
|
||||
</p>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Generate from</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
{AXES.map((ax) => {
|
||||
const on = axisIds.has(ax.id);
|
||||
return (
|
||||
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
|
||||
className="ctv-press"
|
||||
style={{
|
||||
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
|
||||
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
|
||||
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
|
||||
}}>
|
||||
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
|
||||
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Defaults</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
|
||||
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
|
||||
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
|
||||
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel group" hint="Every generated channel lands in this group.">
|
||||
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
|
||||
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
|
||||
{children}
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 2: Preview ------------------------------------------------------
|
||||
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
|
||||
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
|
||||
const selectable = proposals.filter((p) => !p.alreadyExists);
|
||||
const existingCount = proposals.length - selectable.length;
|
||||
|
||||
if (!proposals.length) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
|
||||
<Ico n="SearchX" s={26} />
|
||||
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched — try lowering the minimum items.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setAll = (rows, on) => setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
|
||||
</span>
|
||||
{existingCount > 0 && (
|
||||
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist — deselected</Tag>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
|
||||
</div>
|
||||
|
||||
{groups.map(({ ax, rows }) => {
|
||||
const groupSel = rows.filter((r) => !r.alreadyExists);
|
||||
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
|
||||
const someOn = groupSel.some((r) => selected.has(r.name));
|
||||
return (
|
||||
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
|
||||
<Ico n={ax.icon} s={15} />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
|
||||
<Badge tone="neutral">{rows.length}</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((r, i) => {
|
||||
const on = selected.has(r.name);
|
||||
const ov = overrides[r.name] || {};
|
||||
const name = dispName(r);
|
||||
const isExp = expanded.has(r.name);
|
||||
return (
|
||||
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
|
||||
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
|
||||
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
|
||||
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from “{r.value}”</div>
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
|
||||
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
|
||||
</span>
|
||||
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span> items</Badge>
|
||||
{r.alreadyExists ? (
|
||||
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
|
||||
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
|
||||
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
|
||||
</IconButton>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
{isExp && !r.alreadyExists && (
|
||||
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={eyebrow}>Example schedule</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
|
||||
</div>
|
||||
<MiniEpg blocks={buildSchedule(r, ov)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 3: Results ------------------------------------------------------
|
||||
const RESULT_META = {
|
||||
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
|
||||
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
|
||||
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
|
||||
};
|
||||
function Results({ results, group }) {
|
||||
const count = (s) => results.filter((r) => r.status === s).length;
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
|
||||
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
|
||||
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
|
||||
</div>
|
||||
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
{results.map((r, i) => {
|
||||
const m = RESULT_META[r.status];
|
||||
return (
|
||||
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
|
||||
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
|
||||
</div>
|
||||
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
|
||||
<Badge tone={m.tone}>{r.status}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVAutoTune = AutoTune;
|
||||
})();
|
||||
@@ -0,0 +1,167 @@
|
||||
# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)
|
||||
|
||||
## Overview
|
||||
**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
|
||||
**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
|
||||
library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
|
||||
whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
|
||||
ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
|
||||
and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
|
||||
**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.
|
||||
|
||||
Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
|
||||
weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
|
||||
lineup on every run. Ours adds a preview/select step and is non-destructive.
|
||||
|
||||
The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
|
||||
handoff is **PR2: the SPA screen** that drives them.
|
||||
|
||||
> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
|
||||
> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
|
||||
> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
|
||||
> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
|
||||
> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
|
||||
> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
|
||||
> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
|
||||
> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
|
||||
> #383 arc, not PR2.
|
||||
|
||||
## About the design files
|
||||
The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
|
||||
UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
|
||||
SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
|
||||
prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
|
||||
its mock data and mock outcome logic are illustrative only — the real screen calls the API.
|
||||
|
||||
## Fidelity
|
||||
**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
|
||||
throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
|
||||
are exact.
|
||||
|
||||
---
|
||||
|
||||
## The screen — a 3-step wizard
|
||||
|
||||
One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
|
||||
bottom border) over a scrolling `<main>`. The toolbar is constant across steps; its content and
|
||||
primary action change per step.
|
||||
|
||||
**Toolbar (all steps):**
|
||||
- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
|
||||
`color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
|
||||
semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
|
||||
- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
|
||||
connectors. Each step is a 20px round chip + label. The **active** step: chip
|
||||
`background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
|
||||
**completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
|
||||
`--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
|
||||
- Right: the contextual primary action (below).
|
||||
|
||||
### Step 1 — Configure
|
||||
Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
|
||||
1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
|
||||
the non-destructive promise.
|
||||
2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
|
||||
(one per axis). Each card is a `<button>` (`ctv-press` for the tactile scale-on-press):
|
||||
- Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
|
||||
a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
|
||||
`var(--text-on-accent)`.
|
||||
- Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
|
||||
top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
|
||||
- Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
|
||||
**order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
|
||||
3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
|
||||
(`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
|
||||
- **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
|
||||
- **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
|
||||
- **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
|
||||
- **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.
|
||||
|
||||
**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
|
||||
axis is selected.
|
||||
|
||||
### Step 2 — Preview
|
||||
Centered column, `max-width: 920px`.
|
||||
- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
|
||||
when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
|
||||
only touch selectable — non-existing — rows).
|
||||
- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
|
||||
`var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
|
||||
(`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
|
||||
the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
|
||||
- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
|
||||
`min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
|
||||
neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
|
||||
Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
|
||||
re-create a channel that already exists by that name).
|
||||
- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".
|
||||
|
||||
**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
|
||||
≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.
|
||||
|
||||
### Step 3 — Create (results)
|
||||
Centered column, `max-width: 860px`.
|
||||
- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
|
||||
`CheckCircle2` / `MinusCircle` / `XCircle`).
|
||||
- A **"Added to group {group}"** line (`FolderTree` icon).
|
||||
- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
|
||||
(`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
|
||||
**reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
|
||||
status **Badge** (`positive` / `neutral` / `danger`).
|
||||
|
||||
**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.
|
||||
|
||||
---
|
||||
|
||||
## API mapping (the real screen)
|
||||
|
||||
Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
|
||||
query; the server regenerates it (query authorship is server-side only).
|
||||
|
||||
| UI element | Endpoint / field |
|
||||
|---|---|
|
||||
| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
|
||||
| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
|
||||
| — Minimum items | request `minItems: number` |
|
||||
| — Starting channel number | request `startingNumber: number` |
|
||||
| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
|
||||
| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
|
||||
| — `{itemCount} items` badge | `itemCount` |
|
||||
| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
|
||||
| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
|
||||
| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
|
||||
| — Channel group field | request `group: string` |
|
||||
| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
|
||||
| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |
|
||||
|
||||
Notes:
|
||||
- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
|
||||
**re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
|
||||
batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
|
||||
- **Default selection = every proposal whose `alreadyExists` is false.**
|
||||
- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
|
||||
server's ordering rather than re-sorting client-side.
|
||||
- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
|
||||
template) so the field is never empty; the batch requires a `templateId`.
|
||||
|
||||
## Real-SPA implementation notes (target = `web/`)
|
||||
- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
|
||||
label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
|
||||
group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
|
||||
action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
|
||||
*create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
|
||||
button for this screen.
|
||||
- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
|
||||
`createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
|
||||
re-exported from `web/src/api/index.ts`.
|
||||
- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
|
||||
for the template `Select`.
|
||||
- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
|
||||
`activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
|
||||
not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
|
||||
- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
|
||||
`Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
|
||||
small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
|
||||
- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
|
||||
`docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
|
||||
(if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
|
||||
@@ -0,0 +1,900 @@
|
||||
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
|
||||
// The "automatic-first" creation mode alongside the manual Channel Builder.
|
||||
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
|
||||
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
|
||||
// Additive & non-destructive: never edits or deletes an existing channel; number
|
||||
// or name collisions are skipped, never overwritten. Each generated channel is
|
||||
// backed by a live SmartCollection query so it keeps tracking the library.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
|
||||
|
||||
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
|
||||
// nameOf() and order are display-only here; the real server owns query + name.
|
||||
const AXES = [
|
||||
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
|
||||
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
|
||||
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
|
||||
];
|
||||
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
|
||||
|
||||
// ---- Mock library metadata (EF distinct+count in the real app) ------------
|
||||
const LIBRARY = {
|
||||
TvShow: [
|
||||
{ value: "The Office", count: 201 },
|
||||
{ value: "Friends", count: 236 },
|
||||
{ value: "Breaking Bad", count: 62 },
|
||||
{ value: "Parks and Recreation", count: 125 },
|
||||
{ value: "The Twilight Zone", count: 156 },
|
||||
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
TvGenre: [
|
||||
{ value: "Comedy", count: 640 },
|
||||
{ value: "Drama", count: 512 },
|
||||
{ value: "Sci-Fi", count: 208 },
|
||||
{ value: "Crime", count: 174 },
|
||||
],
|
||||
MovieGenre: [
|
||||
{ value: "Action", count: 42 },
|
||||
{ value: "Sci-Fi", count: 28 },
|
||||
{ value: "Horror", count: 35 },
|
||||
{ value: "Comedy", count: 51 },
|
||||
{ value: "Noir", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
};
|
||||
|
||||
// Coexistence demo: some names already exist (deduped, unchecked by default)
|
||||
// and some numbers are already taken (allocation skips them).
|
||||
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
|
||||
const TAKEN_NUMBERS = new Set(["500", "503"]);
|
||||
|
||||
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
|
||||
|
||||
// ---- Preview computation (advisory numbers, re-validated at create) -------
|
||||
function buildProposals(axisIds, minItems, startingNumber) {
|
||||
const out = [];
|
||||
let next = Math.max(1, startingNumber | 0);
|
||||
const takenThisRun = new Set(TAKEN_NUMBERS);
|
||||
const alloc = () => {
|
||||
while (takenThisRun.has(String(next))) next++;
|
||||
const n = String(next);
|
||||
takenThisRun.add(n);
|
||||
next++;
|
||||
return n;
|
||||
};
|
||||
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
|
||||
AXES.forEach((ax) => {
|
||||
if (!axisIds.has(ax.id)) return;
|
||||
LIBRARY[ax.id]
|
||||
.filter((row) => row.count >= minItems)
|
||||
.slice()
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
.forEach((row) => {
|
||||
const name = ax.nameOf(row.value);
|
||||
const exists = EXISTING_NAMES.has(name);
|
||||
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
|
||||
// explicit initials + color (ChannelLogo only derives them from name). --
|
||||
const BUG_PALETTE = [
|
||||
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
|
||||
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
|
||||
];
|
||||
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
|
||||
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
|
||||
function Bug({ name, initials, ci, size = 32 }) {
|
||||
const idx = ci != null ? ci : bugHash(name);
|
||||
const [fg, bg] = BUG_PALETTE[idx];
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example content sources (advisory sample of the smart-collection query)
|
||||
const GENRE_SHOWS = {
|
||||
Comedy: ["The Office", "Friends", "Parks and Recreation"],
|
||||
Drama: ["Breaking Bad", "The Twilight Zone"],
|
||||
"Sci-Fi": ["The Twilight Zone", "Firefly"],
|
||||
Crime: ["Breaking Bad"],
|
||||
};
|
||||
const MOVIE_TITLES = {
|
||||
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
|
||||
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
|
||||
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
|
||||
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
|
||||
Noir: ["Rain on 5th", "The Long Con"],
|
||||
};
|
||||
const isGenreAxis = (axis) => axis !== "TvShow";
|
||||
function baseSources(p) {
|
||||
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
|
||||
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
|
||||
return []; // TvShow channels track a single show — no per-source editor
|
||||
}
|
||||
function effectiveSources(p, ov) {
|
||||
const ex = ov.exclude || [];
|
||||
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
|
||||
}
|
||||
// Advisory item-count estimate as sources are added/removed (per-source share).
|
||||
function estItems(p, ov) {
|
||||
const base = baseSources(p);
|
||||
if (!base.length) return p.itemCount;
|
||||
const share = p.itemCount / base.length;
|
||||
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
|
||||
}
|
||||
// Sources with their rotation weight (episodes played per rotation).
|
||||
function sourceList(p, ov) {
|
||||
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
|
||||
}
|
||||
|
||||
// Compact rotation-weight stepper (− N +).
|
||||
function Weight({ w, onChange }) {
|
||||
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
|
||||
return (
|
||||
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
|
||||
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
|
||||
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
|
||||
// Effective order for a channel: the axis default, unless overridden per channel.
|
||||
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
|
||||
|
||||
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
|
||||
function buildSchedule(p, ov) {
|
||||
const mins = RUNTIME[p.axis] || 30;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
let seq;
|
||||
if (p.axis === "TvShow") {
|
||||
const n = Math.min(8, Math.max(3, p.itemCount));
|
||||
let eps = Array.from({ length: n }, (_, i) => i + 1);
|
||||
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
|
||||
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
|
||||
} else {
|
||||
const list = sourceList(p, ov).filter((s) => s.weight > 0);
|
||||
if (!list.length) return [];
|
||||
if (shuffled) {
|
||||
// Weighted round-robin interleave — honors the per-source rotation ratio.
|
||||
const total = list.reduce((a, s) => a + s.weight, 0);
|
||||
const st = list.map((s) => ({ ...s, acc: 0 }));
|
||||
const count = Math.min(9, Math.max(5, total * 2));
|
||||
seq = Array.from({ length: count }, () => {
|
||||
let pick = null;
|
||||
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
|
||||
pick.acc -= total;
|
||||
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
|
||||
});
|
||||
} else {
|
||||
// Sequential — each source plays its rotation count in turn.
|
||||
seq = [];
|
||||
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
|
||||
seq = seq.slice(0, 9);
|
||||
}
|
||||
}
|
||||
let t = 20 * 60;
|
||||
return seq.map((b) => {
|
||||
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
|
||||
t += mins;
|
||||
return { ...b, start, mins };
|
||||
});
|
||||
}
|
||||
|
||||
function MiniEpg({ blocks }) {
|
||||
if (!blocks.length) return (
|
||||
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches — nothing to schedule.</div>
|
||||
);
|
||||
const PPM = 2.0;
|
||||
return (
|
||||
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
|
||||
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
|
||||
{blocks.map((b, i) => (
|
||||
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
|
||||
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
|
||||
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value, first }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
|
||||
const CH_TEMPLATES = [
|
||||
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
|
||||
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
|
||||
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
|
||||
];
|
||||
const ADV_GROUPS = [
|
||||
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
|
||||
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
|
||||
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
|
||||
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
|
||||
];
|
||||
|
||||
// Friendly toggle row (label + description + Switch), with an override tag.
|
||||
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
|
||||
return (
|
||||
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
|
||||
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
|
||||
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel Template picker (collapsed row + dropdown + spec chips).
|
||||
function TemplatePicker({ value, onPick }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
|
||||
</div>
|
||||
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{CH_TEMPLATES.map((x) => {
|
||||
const on = x.id === value;
|
||||
return (
|
||||
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
|
||||
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
{t.sets.map((s) => (
|
||||
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Advanced (~24-field) override disclosure: View defaults / Override.
|
||||
function AdvancedSettings({ ov, patch, templateName }) {
|
||||
const [mode, setMode] = React.useState("closed"); // closed | view | override
|
||||
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
|
||||
const vals = { ...defaults, ...(ov.adv || {}) };
|
||||
const override = mode === "override";
|
||||
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||||
const overridden = Object.keys(ov.adv || {}).length;
|
||||
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
|
||||
</div>
|
||||
{mode !== "closed" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
|
||||
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
|
||||
</div>
|
||||
{ADV_GROUPS.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k, v]) => override ? (
|
||||
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
|
||||
function LogoDrop({ name, src, onSet }) {
|
||||
const [over, setOver] = React.useState(false);
|
||||
const fileRef = React.useRef(null);
|
||||
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
|
||||
return (
|
||||
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
|
||||
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
|
||||
</div>
|
||||
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Channel detail slide-over: full per-channel settings -----------------
|
||||
function DetailPanel({ p, ov, patch, onClose }) {
|
||||
const [addText, setAddText] = React.useState("");
|
||||
const name = ov.customName != null ? ov.customName : p.name;
|
||||
const bug = ov.bug || {};
|
||||
const ax = AXIS[p.axis];
|
||||
const number = ov.number != null ? ov.number : p.number;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
const always = ov.always != null ? ov.always : true;
|
||||
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
|
||||
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
|
||||
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
|
||||
const avatar = (size) => bug.src
|
||||
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
|
||||
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
|
||||
const genre = isGenreAxis(p.axis);
|
||||
const base = baseSources(p);
|
||||
const ex = ov.exclude || [];
|
||||
const inc = ov.include || [];
|
||||
const kept = base.filter((s) => !ex.includes(s));
|
||||
const items = estItems(p, ov);
|
||||
const schedule = buildSchedule(p, ov);
|
||||
const ratios = ov.ratios || {};
|
||||
const wOf = (s) => ratios[s] || 1;
|
||||
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
|
||||
const list = [...kept, ...inc];
|
||||
const multi = list.length > 1;
|
||||
|
||||
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
|
||||
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
|
||||
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
|
||||
const addInc = () => {
|
||||
const v = addText.trim();
|
||||
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
|
||||
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
|
||||
setAddText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
|
||||
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
|
||||
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
{avatar(34)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
|
||||
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Channel identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
|
||||
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
|
||||
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</label>
|
||||
</div>
|
||||
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
|
||||
{avatar(48)}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
|
||||
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
{BUG_PALETTE.map((pair, i) => {
|
||||
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
|
||||
return (
|
||||
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
|
||||
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
|
||||
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={eyebrow}>Playback</div>
|
||||
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
|
||||
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Channel Template</div>
|
||||
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Query & size</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
|
||||
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
|
||||
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
|
||||
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
|
||||
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{genre && (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Content sources</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.</span>
|
||||
</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
{list.length === 0 && (
|
||||
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources — add one below.</div>
|
||||
)}
|
||||
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
|
||||
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
|
||||
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
|
||||
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
|
||||
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
|
||||
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{multi && (
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
|
||||
</div>
|
||||
{ex.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
|
||||
{ex.map((s) => (
|
||||
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
|
||||
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Example schedule</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 — the built playout may differ.</span>
|
||||
</div>
|
||||
<MiniEpg blocks={schedule} />
|
||||
</section>
|
||||
|
||||
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
function AutoTune() {
|
||||
const [step, setStep] = React.useState("configure"); // configure | preview | create
|
||||
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
|
||||
const [minItems, setMinItems] = React.useState("5");
|
||||
const [startingNumber, setStartingNumber] = React.useState("500");
|
||||
const [group, setGroup] = React.useState("Auto-Tuned");
|
||||
const [template, setTemplate] = React.useState("Standard");
|
||||
|
||||
const [proposals, setProposals] = React.useState([]);
|
||||
const [selected, setSelected] = React.useState(() => new Set());
|
||||
const [results, setResults] = React.useState(null);
|
||||
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
|
||||
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
|
||||
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
|
||||
|
||||
const patchOv = (name) => (patch) => setOverrides((o) => {
|
||||
const cur = o[name] || {};
|
||||
const delta = typeof patch === "function" ? patch(cur) : patch;
|
||||
return { ...o, [name]: { ...cur, ...delta } };
|
||||
});
|
||||
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
|
||||
|
||||
const toggleAxis = (id) =>
|
||||
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
|
||||
const runPreview = () => {
|
||||
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
|
||||
setProposals(p);
|
||||
// Default selection: everything that isn't an already-existing name.
|
||||
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
|
||||
setStep("preview");
|
||||
};
|
||||
|
||||
const runCreate = () => {
|
||||
// Demo the three outcome states across the selected set.
|
||||
const chosen = proposals.filter((p) => selected.has(p.name));
|
||||
const res = chosen.map((p, i) => {
|
||||
const nm = dispName(p);
|
||||
if (i === chosen.length - 1 && chosen.length > 2)
|
||||
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
|
||||
if (p.name === "Sci-Fi Movies")
|
||||
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
|
||||
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
|
||||
});
|
||||
setResults(res);
|
||||
setStep("create");
|
||||
};
|
||||
|
||||
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
||||
<Toolbar
|
||||
step={step}
|
||||
axisCount={axisIds.size}
|
||||
selectedCount={selected.size}
|
||||
onPreview={runPreview}
|
||||
onCreate={runCreate}
|
||||
onBack={() => setStep("configure")}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{step === "configure" && (
|
||||
<Configure
|
||||
axisIds={axisIds} toggleAxis={toggleAxis}
|
||||
minItems={minItems} setMinItems={setMinItems}
|
||||
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
|
||||
group={group} setGroup={setGroup}
|
||||
template={template} setTemplate={setTemplate}
|
||||
/>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
|
||||
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
|
||||
openDetail={setDetail} dispName={dispName} />
|
||||
)}
|
||||
{step === "create" && <Results results={results} group={group} />}
|
||||
</main>
|
||||
{detail != null && (() => {
|
||||
const p = proposals.find((x) => x.name === detail);
|
||||
if (!p) return null;
|
||||
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Toolbar with step rail + contextual primary action -------------------
|
||||
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, 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="Sparkles" s={19} /></span>
|
||||
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
<StepRail step={step} />
|
||||
</div>
|
||||
{step === "configure" && (
|
||||
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
|
||||
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<React.Fragment>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
|
||||
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
|
||||
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
|
||||
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{step === "create" && (
|
||||
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ id: "configure", label: "Configure" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "create", label: "Create" },
|
||||
];
|
||||
function StepRail({ step }) {
|
||||
const idx = STEPS.findIndex((s) => s.id === step);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < idx, active = i === idx;
|
||||
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
|
||||
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
|
||||
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
|
||||
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
|
||||
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
|
||||
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 1: Configure ----------------------------------------------------
|
||||
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
|
||||
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
|
||||
preview the proposed channels, then create the ones you want. Existing channels are never touched.
|
||||
</p>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Generate from</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
{AXES.map((ax) => {
|
||||
const on = axisIds.has(ax.id);
|
||||
return (
|
||||
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
|
||||
className="ctv-press"
|
||||
style={{
|
||||
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
|
||||
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
|
||||
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
|
||||
}}>
|
||||
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
|
||||
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Defaults</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
|
||||
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
|
||||
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
|
||||
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel group" hint="Every generated channel lands in this group.">
|
||||
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
|
||||
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
|
||||
{children}
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 2: Preview ------------------------------------------------------
|
||||
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
|
||||
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
|
||||
const selectable = proposals.filter((p) => !p.alreadyExists);
|
||||
const existingCount = proposals.length - selectable.length;
|
||||
|
||||
if (!proposals.length) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
|
||||
<Ico n="SearchX" s={26} />
|
||||
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched — try lowering the minimum items.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setAll = (rows, on) => setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
|
||||
</span>
|
||||
{existingCount > 0 && (
|
||||
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist — deselected</Tag>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
|
||||
</div>
|
||||
|
||||
{groups.map(({ ax, rows }) => {
|
||||
const groupSel = rows.filter((r) => !r.alreadyExists);
|
||||
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
|
||||
const someOn = groupSel.some((r) => selected.has(r.name));
|
||||
return (
|
||||
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
|
||||
<Ico n={ax.icon} s={15} />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
|
||||
<Badge tone="neutral">{rows.length}</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((r, i) => {
|
||||
const on = selected.has(r.name);
|
||||
const ov = overrides[r.name] || {};
|
||||
const name = dispName(r);
|
||||
const isExp = expanded.has(r.name);
|
||||
return (
|
||||
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
|
||||
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
|
||||
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
|
||||
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from “{r.value}”</div>
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
|
||||
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
|
||||
</span>
|
||||
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span> items</Badge>
|
||||
{r.alreadyExists ? (
|
||||
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
|
||||
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
|
||||
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
|
||||
</IconButton>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
{isExp && !r.alreadyExists && (
|
||||
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={eyebrow}>Example schedule</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
|
||||
</div>
|
||||
<MiniEpg blocks={buildSchedule(r, ov)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 3: Results ------------------------------------------------------
|
||||
const RESULT_META = {
|
||||
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
|
||||
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
|
||||
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
|
||||
};
|
||||
function Results({ results, group }) {
|
||||
const count = (s) => results.filter((r) => r.status === s).length;
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
|
||||
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
|
||||
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
|
||||
</div>
|
||||
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
{results.map((r, i) => {
|
||||
const m = RESULT_META[r.status];
|
||||
return (
|
||||
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
|
||||
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
|
||||
</div>
|
||||
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
|
||||
<Badge tone={m.tone}>{r.status}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVAutoTune = AutoTune;
|
||||
})();
|
||||
@@ -8,6 +8,7 @@
|
||||
{ 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 },
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
<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="Playouts.jsx" data-presets="react"></script>
|
||||
@@ -90,7 +91,7 @@
|
||||
const { Sidebar, TopBar } = window.CTVShell;
|
||||
|
||||
const TITLES = {
|
||||
dashboard: "Dashboard", channels: "Channels", builder: "New Channel", guide: "Guide", schedules: "Schedules",
|
||||
dashboard: "Dashboard", channels: "Channels", builder: "New Channel", autoTune: "Auto-Tune", guide: "Guide", schedules: "Schedules",
|
||||
playouts: "Playouts", collections: "Collections", libraries: "Libraries", settings: "Settings",
|
||||
};
|
||||
|
||||
@@ -110,6 +111,7 @@ function App() {
|
||||
dashboard: window.CTVDashboard,
|
||||
channels: window.CTVChannels,
|
||||
builder: window.CTVChannelBuilder,
|
||||
autoTune: window.CTVAutoTune,
|
||||
guide: window.CTVEpg,
|
||||
schedules: window.CTVScheduleEditor,
|
||||
playouts: window.CTVPlayouts,
|
||||
@@ -132,7 +134,7 @@ function App() {
|
||||
|
||||
function mount() {
|
||||
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
|
||||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVScheduleEditor || !window.CTVEpg ||
|
||||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVAutoTune || !window.CTVScheduleEditor || !window.CTVEpg ||
|
||||
!window.CTVPlayouts || !window.CTVLibraries || !window.CTVSettings) {
|
||||
return setTimeout(mount, 40);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,15 @@ section is now empty (mappings retained at the top of this section for the recor
|
||||
| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (family=emby); **#202** |
|
||||
| `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** |
|
||||
|
||||
## Section 2b — SPA-NATIVE (no Blazor ancestor)
|
||||
|
||||
Screens the SPA introduces that never existed in the Blazor UI, so they have no parity row and no
|
||||
redirect (nothing to redirect *from*). Recorded here so the parity tracker is complete.
|
||||
|
||||
| SPA route | Screen | Notes |
|
||||
|---|---|---|
|
||||
| `/app/auto-tune` | `AutoTuneScreen` | **Net-new (#69).** Auto-Tune wizard: bulk-generate channels from library metadata (axes: TV Shows / TV Genres / Movie Genres) → preview & select → bulk-create, over the PR1 `POST /api/v1/channels/auto-tune/preview` + `POST /api/v1/channels/auto-tune` endpoints. Additive/non-destructive; no Blazor equivalent. Per-channel DetailPanel deferred to #383. |
|
||||
|
||||
## Section 3 — BLAZOR-ONLY (blocking issues)
|
||||
|
||||
### Playlist variant management — API gap #153 RESOLVED
|
||||
|
||||
@@ -75,7 +75,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` |
|
||||
| **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) |
|
||||
| **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` |
|
||||
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel` |
|
||||
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69) |
|
||||
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
|
||||
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
|
||||
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
|
||||
@@ -83,7 +83,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
|
||||
## Where things are edited (SPA routes)
|
||||
|
||||
Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/guide`,
|
||||
Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/auto-tune` (#69), `/app/guide`,
|
||||
`/app/schedules`, `/app/blocks`, `/app/templates`, `/app/decos`, `/app/deco-templates`,
|
||||
`/app/playouts` (+ sub-paths `/app/playouts/{id}/alternate-schedules`,
|
||||
`/app/playouts/{id}/templates`).
|
||||
|
||||
@@ -99,6 +99,19 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
|
||||
});
|
||||
|
||||
it('routes to the Auto-Tune wizard from its sidebar nav entry (#69)', async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Auto-Tune' }));
|
||||
|
||||
expect(window.location.pathname).toBe('/app/auto-tune');
|
||||
// The wizard mounts on its Configure step with an in-body Preview action (no TopBar "+").
|
||||
expect(await screen.findByRole('button', { name: /TV Shows/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeInTheDocument();
|
||||
// Loads channel templates on mount (its own endpoint dependency).
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
|
||||
});
|
||||
|
||||
it('registers the multi-collection and rerun-collection nav entries', async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createAutoTunedChannels, previewAutoTune } from './autoTune';
|
||||
|
||||
const sampleProposals = [
|
||||
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
|
||||
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: true }
|
||||
];
|
||||
|
||||
const sampleResult = {
|
||||
results: [{ name: 'The Office', status: 'Created', channelId: 80, reason: null }],
|
||||
createdCount: 1,
|
||||
skippedCount: 0,
|
||||
failedCount: 0
|
||||
};
|
||||
|
||||
describe('previewAutoTune', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('POSTs /api/v1/channels/auto-tune/preview with the axes/minItems/startingNumber body', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleProposals), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
previewAutoTune({ axes: ['TvShow', 'TvGenre'], minItems: 5, startingNumber: 500 })
|
||||
).resolves.toHaveLength(2);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/channels/auto-tune/preview');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
axes: ['TvShow', 'TvGenre'],
|
||||
minItems: 5,
|
||||
startingNumber: 500
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on failure', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 500
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
previewAutoTune({ axes: ['TvShow'], minItems: 1, startingNumber: 1 })
|
||||
).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAutoTunedChannels', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('POSTs /api/v1/channels/auto-tune with templateId, group and echoed channels', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleResult), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createAutoTunedChannels({
|
||||
templateId: 3,
|
||||
group: 'Auto-Tuned',
|
||||
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
|
||||
})
|
||||
).resolves.toMatchObject({ createdCount: 1 });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/channels/auto-tune');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
templateId: 3,
|
||||
group: 'Auto-Tuned',
|
||||
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type AutoTuneAxis = components['schemas']['AutoTuneAxis'];
|
||||
export type AutoTuneProposal = components['schemas']['AutoTuneProposalResponseModel'];
|
||||
export type PreviewAutoTuneChannelsRequest = components['schemas']['PreviewAutoTuneChannelsRequest'];
|
||||
export type AutoTunedChannelRequest = components['schemas']['AutoTunedChannelRequest'];
|
||||
export type CreateAutoTunedChannelsRequest = components['schemas']['CreateAutoTunedChannelsRequest'];
|
||||
export type AutoTuneChannelResult = components['schemas']['AutoTuneChannelResultModel'];
|
||||
export type AutoTuneResult = components['schemas']['AutoTuneResultResponseModel'];
|
||||
|
||||
export function previewAutoTune(body: PreviewAutoTuneChannelsRequest): Promise<AutoTuneProposal[]> {
|
||||
return request<AutoTuneProposal[]>('/api/v1/channels/auto-tune/preview', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function createAutoTunedChannels(body: CreateAutoTunedChannelsRequest): Promise<AutoTuneResult> {
|
||||
return request<AutoTuneResult>('/api/v1/channels/auto-tune', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function messageFromAutoTuneError(error: unknown, fallback = 'Unable to auto-tune channels'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './artwork';
|
||||
export * from './auth';
|
||||
export * from './autoTune';
|
||||
export * from './blocks';
|
||||
export * from './channels';
|
||||
export * from './channelTemplates';
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseMediaSubRoute
|
||||
} from '../routing';
|
||||
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
|
||||
import { AutoTuneScreen } from '../screens/AutoTuneScreen';
|
||||
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
|
||||
import { BlocksScreen } from '../screens/BlocksScreen';
|
||||
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
|
||||
@@ -165,6 +166,8 @@ export function ScreenContent({
|
||||
return <ChannelsScreen />;
|
||||
case 'builder':
|
||||
return <ChannelBuilderScreen />;
|
||||
case 'autoTune':
|
||||
return <AutoTuneScreen />;
|
||||
case 'editChannel':
|
||||
return <ChannelEditScreen key={window.location.pathname} />;
|
||||
case 'guide':
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Stamp,
|
||||
Stethoscope,
|
||||
Trash2,
|
||||
@@ -33,6 +34,7 @@ export type ScreenId =
|
||||
| 'dashboard'
|
||||
| 'channels'
|
||||
| 'builder'
|
||||
| 'autoTune'
|
||||
| 'editChannel'
|
||||
| 'guide'
|
||||
| 'schedules'
|
||||
@@ -113,6 +115,19 @@ export const routes: ScreenRoute[] = [
|
||||
primaryAction: '',
|
||||
placeholder: 'Channel builder workspace'
|
||||
},
|
||||
{
|
||||
// Net-new SPA screen with no Blazor ancestor (#69). The 3-step wizard drives its own in-body
|
||||
// buttons, so it registers no TopBar primaryAction (spa-conventions §10).
|
||||
id: 'autoTune',
|
||||
path: '/app/auto-tune',
|
||||
label: 'Auto-Tune',
|
||||
title: 'Auto-Tune',
|
||||
kicker: 'Builder',
|
||||
description: 'Generate a whole channel lineup from your library metadata, preview it, then bulk-create.',
|
||||
icon: <Sparkles aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Auto-tune workspace'
|
||||
},
|
||||
{
|
||||
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
|
||||
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
|
||||
@@ -458,6 +473,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
|
||||
'dashboard',
|
||||
'channels',
|
||||
'builder',
|
||||
'autoTune',
|
||||
'guide',
|
||||
'schedules',
|
||||
'blocks',
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AutoTuneScreen } from './AutoTuneScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
const templates = [
|
||||
{ id: 1, name: 'Standard' },
|
||||
{ id: 2, name: 'Movie night' }
|
||||
];
|
||||
|
||||
const proposals = [
|
||||
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
|
||||
{ axis: 'TvShow', value: 'Friends', name: 'Friends', number: '501', itemCount: 236, alreadyExists: true },
|
||||
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502', itemCount: 640, alreadyExists: false }
|
||||
];
|
||||
|
||||
const createResult = {
|
||||
results: [
|
||||
{ name: 'The Office', status: 'Created', channelId: 80, reason: null },
|
||||
{ name: 'Comedy', status: 'Skipped', channelId: null, reason: 'number 502 already taken' }
|
||||
],
|
||||
createdCount: 1,
|
||||
skippedCount: 1,
|
||||
failedCount: 0
|
||||
};
|
||||
|
||||
function mockAutoTuneApi(): ReturnType<typeof vi.spyOn> {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (path === '/api/v1/channel-templates') {
|
||||
return Promise.resolve(jsonResponse(templates));
|
||||
}
|
||||
if (path === '/api/v1/channel-templates/default') {
|
||||
return Promise.resolve(jsonResponse(templates[0]));
|
||||
}
|
||||
if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(proposals));
|
||||
}
|
||||
if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(createResult));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(null, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe('AutoTuneScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('gates the Preview action on at least one selected axis', async () => {
|
||||
mockAutoTuneApi();
|
||||
render(<AutoTuneScreen />);
|
||||
|
||||
// All three axes start selected, so Preview is enabled.
|
||||
const previewButton = screen.getByRole('button', { name: /Preview channels/ });
|
||||
expect(previewButton).toBeEnabled();
|
||||
|
||||
// Deselect every axis → Preview disabled.
|
||||
fireEvent.click(screen.getByRole('button', { name: /TV Shows/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /TV Genres/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Movie Genres/ }));
|
||||
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeDisabled();
|
||||
|
||||
// Re-select one → enabled again.
|
||||
fireEvent.click(screen.getByRole('button', { name: /TV Shows/ }));
|
||||
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders preview groups and deselects already-existing proposals', async () => {
|
||||
mockAutoTuneApi();
|
||||
render(<AutoTuneScreen />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
|
||||
|
||||
// Group headers appear in axis order.
|
||||
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
|
||||
expect(screen.getByText('TV Genres')).toBeInTheDocument();
|
||||
|
||||
// Non-existing proposals are selected by default.
|
||||
expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('checkbox', { name: 'Comedy' })).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// The already-existing proposal is disabled + unchecked.
|
||||
const existing = screen.getByRole('checkbox', { name: 'Friends' });
|
||||
expect(existing).toHaveAttribute('aria-checked', 'false');
|
||||
expect(existing).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(screen.getByText('Exists')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports select-all / clear over the selectable proposals', async () => {
|
||||
mockAutoTuneApi();
|
||||
render(<AutoTuneScreen />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('button', { name: /Create channels/ })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByRole('button', { name: /Create 2 channels/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('posts the selected proposals and renders the result tiles', async () => {
|
||||
const fetchSpy = mockAutoTuneApi();
|
||||
render(<AutoTuneScreen />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
|
||||
|
||||
// Result view renders on success (reason + group line are unique to it).
|
||||
expect(await screen.findByText('number 502 already taken')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Added to group/)).toBeInTheDocument();
|
||||
expect(screen.getByText('#80')).toBeInTheDocument();
|
||||
// "Created" / "Skipped" appear both as Stat tile labels and per-row status badges.
|
||||
expect(screen.getAllByText('Created').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText('Skipped').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
|
||||
// The create request echoes only the selected (non-existing) proposals.
|
||||
const createCall = fetchSpy.mock.calls.find(
|
||||
([url, init]: [RequestInfo | URL, RequestInit | undefined]) =>
|
||||
url === '/api/v1/channels/auto-tune' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
expect(createCall).toBeTruthy();
|
||||
const body = JSON.parse(String(createCall?.[1]?.body));
|
||||
expect(body.templateId).toBe(1);
|
||||
expect(body.group).toBe('Auto-Tuned');
|
||||
expect(body.channels).toEqual([
|
||||
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' },
|
||||
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keys selection by axis+value so same-named proposals stay independent', async () => {
|
||||
// A TV show literally titled "Comedy" and the "Comedy" TV genre — identical names, different
|
||||
// axes. Keying selection on the name would couple them (toggling one flips both).
|
||||
const collision = [
|
||||
{ axis: 'TvShow', value: 'Comedy', name: 'Comedy', number: '500', itemCount: 30, alreadyExists: false },
|
||||
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: false }
|
||||
];
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (path === '/api/v1/channel-templates') {
|
||||
return Promise.resolve(jsonResponse(templates));
|
||||
}
|
||||
if (path === '/api/v1/channel-templates/default') {
|
||||
return Promise.resolve(jsonResponse(templates[0]));
|
||||
}
|
||||
if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse(collision));
|
||||
}
|
||||
if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ results: [], createdCount: 0, skippedCount: 0, failedCount: 0 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(null, 404));
|
||||
});
|
||||
|
||||
render(<AutoTuneScreen />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
// Two distinct row checkboxes both named "Comedy" (one per axis), both selected by default.
|
||||
const boxes = screen.getAllByRole('checkbox', { name: 'Comedy' });
|
||||
expect(boxes).toHaveLength(2);
|
||||
expect(boxes[0]).toHaveAttribute('aria-checked', 'true');
|
||||
expect(boxes[1]).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// Deselecting the TV-show "Comedy" must NOT deselect the genre "Comedy".
|
||||
fireEvent.click(boxes[0]);
|
||||
const after = screen.getAllByRole('checkbox', { name: 'Comedy' });
|
||||
expect(after[0]).toHaveAttribute('aria-checked', 'false');
|
||||
expect(after[1]).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
// Exactly one remains selected → create posts only the still-selected genre proposal.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create 1 channel/ }));
|
||||
const createCall = fetchSpy.mock.calls.find(
|
||||
(call) => call[0] === '/api/v1/channels/auto-tune' && (call[1]?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
const body = JSON.parse(String(createCall?.[1]?.body));
|
||||
expect(body.channels).toEqual([{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,876 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clapperboard,
|
||||
Eye,
|
||||
Film,
|
||||
FolderTree,
|
||||
Hash,
|
||||
ListOrdered,
|
||||
MinusCircle,
|
||||
RotateCcw,
|
||||
SearchX,
|
||||
Shuffle,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
Tv,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import { Badge, Button, Checkbox, Input, Select, Spinner, Stat, Tag, Tooltip } from '../components';
|
||||
import {
|
||||
createAutoTunedChannels,
|
||||
getChannelTemplates,
|
||||
getDefaultChannelTemplate,
|
||||
messageFromAutoTuneError,
|
||||
messageFromChannelTemplateError,
|
||||
previewAutoTune,
|
||||
type AutoTuneAxis,
|
||||
type AutoTuneProposal,
|
||||
type AutoTuneResult,
|
||||
type ChannelTemplate
|
||||
} from '../api';
|
||||
|
||||
type WizardStep = 'configure' | 'preview' | 'create';
|
||||
|
||||
interface AxisMeta {
|
||||
id: AutoTuneAxis;
|
||||
title: string;
|
||||
tagline: string;
|
||||
icon: typeof Tv;
|
||||
order: string;
|
||||
shuffled: boolean;
|
||||
}
|
||||
|
||||
// Display catalogue only — the server owns the query and channel name. Order here mirrors the
|
||||
// server grouping (TvShow → TvGenre → MovieGenre).
|
||||
const AXES: AxisMeta[] = [
|
||||
{ id: 'TvShow', title: 'TV Shows', tagline: 'One 24/7 channel per show', icon: Tv, order: 'Episode order', shuffled: false },
|
||||
{ id: 'TvGenre', title: 'TV Genres', tagline: 'A channel per episode genre', icon: Clapperboard, order: 'Shuffled', shuffled: true },
|
||||
{ id: 'MovieGenre', title: 'Movie Genres', tagline: 'A movie channel per genre', icon: Film, order: 'Shuffled', shuffled: true }
|
||||
];
|
||||
|
||||
const eyebrowStyle: CSSProperties = {
|
||||
font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-disabled)'
|
||||
};
|
||||
|
||||
const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' };
|
||||
|
||||
// Selection identity — axis+value uniquely identifies a proposal. Keying on the display
|
||||
// name would couple two same-named proposals (e.g. a TV show titled "Comedy" and the
|
||||
// "Comedy" genre), flipping/creating both. An axis id never contains a space, so a single
|
||||
// space separates it unambiguously from the metadata value.
|
||||
const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`;
|
||||
|
||||
type TemplatesState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'success'; templates: ChannelTemplate[] };
|
||||
|
||||
type PreviewState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'success'; proposals: AutoTuneProposal[] };
|
||||
|
||||
type CreateState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'success'; result: AutoTuneResult };
|
||||
|
||||
const RESULT_META: Record<string, { icon: typeof CheckCircle2; tone: 'ok' | 'neutral' | 'error'; color: string }> = {
|
||||
Created: { icon: CheckCircle2, tone: 'ok', color: 'var(--status-ok)' },
|
||||
Skipped: { icon: MinusCircle, tone: 'neutral', color: 'var(--text-secondary)' },
|
||||
Failed: { icon: XCircle, tone: 'error', color: 'var(--status-error)' }
|
||||
};
|
||||
|
||||
export function AutoTuneScreen() {
|
||||
const [step, setStep] = useState<WizardStep>('configure');
|
||||
const [axisIds, setAxisIds] = useState<Set<AutoTuneAxis>>(() => new Set<AutoTuneAxis>(['TvShow', 'TvGenre', 'MovieGenre']));
|
||||
const [minItems, setMinItems] = useState('5');
|
||||
const [startingNumber, setStartingNumber] = useState('500');
|
||||
const [group, setGroup] = useState('Auto-Tuned');
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
const [templatesState, setTemplatesState] = useState<TemplatesState>({ status: 'loading' });
|
||||
const [previewState, setPreviewState] = useState<PreviewState>({ status: 'idle' });
|
||||
const [createState, setCreateState] = useState<CreateState>({ status: 'idle' });
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const activeRef = useRef(true);
|
||||
const templatesSeqRef = useRef(0);
|
||||
const previewSeqRef = useRef(0);
|
||||
const createSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadTemplates = useCallback(() => {
|
||||
const id = ++templatesSeqRef.current;
|
||||
Promise.all([getChannelTemplates(), getDefaultChannelTemplate()])
|
||||
.then(([templates, preferred]) => {
|
||||
if (!activeRef.current || id !== templatesSeqRef.current) {
|
||||
return;
|
||||
}
|
||||
setTemplatesState({ status: 'success', templates });
|
||||
const fallback = preferred ?? templates[0] ?? null;
|
||||
if (fallback) {
|
||||
setTemplateId(String(fallback.id));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current && id === templatesSeqRef.current) {
|
||||
setTemplatesState({ status: 'error', message: messageFromChannelTemplateError(error) });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const toggleAxis = (id: AutoTuneAxis) => {
|
||||
setAxisIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const runPreview = () => {
|
||||
if (axisIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const id = ++previewSeqRef.current;
|
||||
setStep('preview');
|
||||
setPreviewState({ status: 'loading' });
|
||||
previewAutoTune({
|
||||
axes: AXES.filter((axis) => axisIds.has(axis.id)).map((axis) => axis.id),
|
||||
minItems: Math.max(1, Number.parseInt(minItems, 10) || 1),
|
||||
startingNumber: Number.parseInt(startingNumber, 10) || 1
|
||||
})
|
||||
.then((proposals) => {
|
||||
if (!activeRef.current || id !== previewSeqRef.current) {
|
||||
return;
|
||||
}
|
||||
setPreviewState({ status: 'success', proposals });
|
||||
// Default selection: every proposal that doesn't already exist.
|
||||
setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map(proposalKey)));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current && id === previewSeqRef.current) {
|
||||
setPreviewState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to preview channels') });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const proposals = previewState.status === 'success' ? previewState.proposals : [];
|
||||
const selectedProposals = proposals.filter(
|
||||
(proposal) => !proposal.alreadyExists && selected.has(proposalKey(proposal))
|
||||
);
|
||||
|
||||
const runCreate = () => {
|
||||
if (selectedProposals.length === 0 || templateId === '') {
|
||||
return;
|
||||
}
|
||||
const id = ++createSeqRef.current;
|
||||
setStep('create');
|
||||
setCreateState({ status: 'loading' });
|
||||
createAutoTunedChannels({
|
||||
templateId: Number(templateId),
|
||||
group,
|
||||
channels: selectedProposals.map((proposal) => ({
|
||||
axis: proposal.axis as AutoTuneAxis,
|
||||
value: proposal.value,
|
||||
name: proposal.name,
|
||||
number: proposal.number
|
||||
}))
|
||||
})
|
||||
.then((result) => {
|
||||
if (activeRef.current && id === createSeqRef.current) {
|
||||
setCreateState({ status: 'success', result });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current && id === createSeqRef.current) {
|
||||
setCreateState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to create channels') });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
setPreviewState({ status: 'idle' });
|
||||
setCreateState({ status: 'idle' });
|
||||
setSelected(new Set());
|
||||
setStep('configure');
|
||||
};
|
||||
|
||||
const templateOptions =
|
||||
templatesState.status === 'success'
|
||||
? templatesState.templates.map((template) => ({ value: String(template.id), label: template.name }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
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'
|
||||
}}
|
||||
>
|
||||
<Sparkles aria-hidden="true" size={19} />
|
||||
</span>
|
||||
<div style={{ flex: '0 0 auto', minWidth: 0 }}>
|
||||
<h2 style={{ margin: 0, font: 'var(--weight-semibold) var(--text-md)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
|
||||
Auto-Tune
|
||||
</h2>
|
||||
<div style={{ marginTop: 3, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
|
||||
Generate channels from your library
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<StepRail step={step} />
|
||||
</div>
|
||||
{step === 'configure' && (
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
label={axisIds.size ? 'Enumerate the library and preview proposed channels' : 'Pick at least one metadata axis'}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={axisIds.size === 0}
|
||||
startIcon={<Eye aria-hidden="true" size={15} />}
|
||||
onClick={runPreview}
|
||||
>
|
||||
Preview channels
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{step === 'preview' && (
|
||||
<>
|
||||
<Button variant="ghost" startIcon={<ArrowLeft aria-hidden="true" size={15} />} onClick={() => setStep('configure')}>
|
||||
Back
|
||||
</Button>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
label={
|
||||
selectedProposals.length === 0
|
||||
? 'Select at least one channel'
|
||||
: templateId === ''
|
||||
? 'Choose a channel template on the Configure step first'
|
||||
: 'Create the selected channels'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={selectedProposals.length === 0 || templateId === ''}
|
||||
startIcon={<Check aria-hidden="true" size={15} />}
|
||||
onClick={runCreate}
|
||||
>
|
||||
{selectedProposals.length
|
||||
? `Create ${selectedProposals.length} channel${selectedProposals.length === 1 ? '' : 's'}`
|
||||
: 'Create channels'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{step === 'create' && (
|
||||
<Button variant="primary" startIcon={<RotateCcw aria-hidden="true" size={15} />} onClick={restart}>
|
||||
Start over
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<main style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
{step === 'configure' && (
|
||||
<ConfigureStep
|
||||
axisIds={axisIds}
|
||||
toggleAxis={toggleAxis}
|
||||
minItems={minItems}
|
||||
setMinItems={setMinItems}
|
||||
startingNumber={startingNumber}
|
||||
setStartingNumber={setStartingNumber}
|
||||
group={group}
|
||||
setGroup={setGroup}
|
||||
templateId={templateId}
|
||||
setTemplateId={setTemplateId}
|
||||
templatesState={templatesState}
|
||||
templateOptions={templateOptions}
|
||||
retryTemplates={loadTemplates}
|
||||
/>
|
||||
)}
|
||||
{step === 'preview' && (
|
||||
<PreviewStep
|
||||
state={previewState}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
onRetry={runPreview}
|
||||
/>
|
||||
)}
|
||||
{step === 'create' && <CreateStep state={createState} group={group} onRetry={runCreate} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS: { id: WizardStep; label: string }[] = [
|
||||
{ id: 'configure', label: 'Configure' },
|
||||
{ id: 'preview', label: 'Preview' },
|
||||
{ id: 'create', label: 'Create' }
|
||||
];
|
||||
|
||||
function StepRail({ step }: { step: WizardStep }) {
|
||||
const index = STEPS.findIndex((entry) => entry.id === step);
|
||||
return (
|
||||
<ol style={{ display: 'flex', alignItems: 'center', gap: 4, margin: 0, padding: 0, listStyle: 'none' }}>
|
||||
{STEPS.map((entry, i) => {
|
||||
const done = i < index;
|
||||
const active = i === index;
|
||||
const color = active ? 'var(--ctv-accent)' : done ? 'var(--text-secondary)' : 'var(--text-disabled)';
|
||||
return (
|
||||
<li key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)',
|
||||
background: active ? 'var(--ctv-accent)' : done ? 'var(--ctv-accent-soft)' : 'var(--ctv-surface-2)',
|
||||
color: active ? 'var(--text-on-accent)' : done ? 'var(--ctv-accent)' : 'var(--text-disabled)'
|
||||
}}
|
||||
>
|
||||
{done ? <Check aria-hidden="true" size={12} /> : i + 1}
|
||||
</span>
|
||||
<span
|
||||
aria-current={active ? 'step' : undefined}
|
||||
style={{
|
||||
font: `${active ? 'var(--weight-semibold)' : 'var(--weight-medium)'} var(--text-xs)/1 var(--font-sans)`,
|
||||
color
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
</span>
|
||||
{i < STEPS.length - 1 && (
|
||||
<span aria-hidden="true" style={{ width: 26, height: 1, background: 'var(--border-control)', margin: '0 4px' }} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint: string; children: ReactNode }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>{label}</span>
|
||||
{children}
|
||||
<span style={{ font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigureStep({
|
||||
axisIds,
|
||||
toggleAxis,
|
||||
minItems,
|
||||
setMinItems,
|
||||
startingNumber,
|
||||
setStartingNumber,
|
||||
group,
|
||||
setGroup,
|
||||
templateId,
|
||||
setTemplateId,
|
||||
templatesState,
|
||||
templateOptions,
|
||||
retryTemplates
|
||||
}: {
|
||||
axisIds: Set<AutoTuneAxis>;
|
||||
toggleAxis: (id: AutoTuneAxis) => void;
|
||||
minItems: string;
|
||||
setMinItems: (value: string) => void;
|
||||
startingNumber: string;
|
||||
setStartingNumber: (value: string) => void;
|
||||
group: string;
|
||||
setGroup: (value: string) => void;
|
||||
templateId: string;
|
||||
setTemplateId: (value: string) => void;
|
||||
templatesState: TemplatesState;
|
||||
templateOptions: { value: string; label: string }[];
|
||||
retryTemplates: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<p style={{ margin: 0, font: 'var(--text-sm)/1.55 var(--font-sans)', color: 'var(--text-secondary)', maxWidth: 620 }}>
|
||||
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from, preview the proposed
|
||||
channels, then create the ones you want. Existing channels are never touched.
|
||||
</p>
|
||||
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={eyebrowStyle}>Generate from</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
|
||||
{AXES.map((axis) => {
|
||||
const on = axisIds.has(axis.id);
|
||||
const AxisIcon = axis.icon;
|
||||
const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
|
||||
return (
|
||||
<button
|
||||
key={axis.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => toggleAxis(axis.id)}
|
||||
className="ctv-press"
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
padding: '16px 16px 15px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: `1px solid ${on ? 'var(--ctv-accent)' : 'var(--border-control)'}`,
|
||||
background: on ? 'var(--ctv-accent-soft)' : 'var(--surface-card)',
|
||||
boxShadow: on ? 'var(--shadow-sm)' : 'none',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 9
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'absolute', top: 12, right: 12, color: on ? 'var(--ctv-accent)' : 'var(--text-disabled)' }}>
|
||||
{on ? <CheckCircle2 aria-hidden="true" size={17} /> : <Circle aria-hidden="true" size={17} />}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: on ? 'var(--ctv-accent)' : 'var(--ctv-surface-2)',
|
||||
color: on ? 'var(--text-on-accent)' : 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
<AxisIcon aria-hidden="true" size={17} />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
|
||||
{axis.title}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: 'var(--text-xs)/1.35 var(--font-sans)', color: 'var(--text-secondary)' }}>
|
||||
{axis.tagline}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
font: 'var(--text-2xs)/1 var(--font-sans)',
|
||||
color: 'var(--text-disabled)'
|
||||
}}
|
||||
>
|
||||
<OrderIcon aria-hidden="true" size={11} />
|
||||
{axis.order}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={eyebrowStyle}>Defaults</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 14, alignItems: 'start' }}>
|
||||
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
|
||||
<Input
|
||||
size="sm"
|
||||
type="number"
|
||||
value={minItems}
|
||||
onChange={(event) => setMinItems(event.target.value)}
|
||||
leadingIcon={<Hash aria-hidden="true" size={14} />}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
|
||||
<Input
|
||||
size="sm"
|
||||
type="number"
|
||||
value={startingNumber}
|
||||
onChange={(event) => setStartingNumber(event.target.value)}
|
||||
leadingIcon={<Tv aria-hidden="true" size={14} />}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Channel group" hint="Every generated channel lands in this group.">
|
||||
<Input
|
||||
size="sm"
|
||||
value={group}
|
||||
onChange={(event) => setGroup(event.target.value)}
|
||||
leadingIcon={<FolderTree aria-hidden="true" size={14} />}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
|
||||
{templatesState.status === 'loading' ? (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '4px 2px' }} role="status">
|
||||
<Spinner size={14} />
|
||||
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading templates…</span>
|
||||
</div>
|
||||
) : templatesState.status === 'error' ? (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
|
||||
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--status-error)' }}>{templatesState.message}</span>
|
||||
<Button size="sm" variant="secondary" onClick={retryTemplates}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : templateOptions.length === 0 ? (
|
||||
<div
|
||||
role="status"
|
||||
style={{ font: 'var(--text-xs)/1.4 var(--font-sans)', color: 'var(--text-secondary)', padding: '4px 2px' }}
|
||||
>
|
||||
No channel templates yet — create one under Templates first.
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
size="sm"
|
||||
value={templateId}
|
||||
onChange={(event) => setTemplateId(event.target.value)}
|
||||
options={templateOptions}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStep({
|
||||
state,
|
||||
selected,
|
||||
setSelected,
|
||||
onRetry
|
||||
}: {
|
||||
state: PreviewState;
|
||||
selected: Set<string>;
|
||||
setSelected: (updater: (current: Set<string>) => Set<string>) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (state.status === 'loading' || state.status === 'idle') {
|
||||
return (
|
||||
<div style={centeredMessageStyle} role="status">
|
||||
<Spinner size={22} />
|
||||
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Enumerating your library…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div style={centeredMessageStyle} role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
|
||||
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
|
||||
<Button size="sm" variant="secondary" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { proposals } = state;
|
||||
|
||||
if (proposals.length === 0) {
|
||||
return (
|
||||
<div style={centeredMessageStyle}>
|
||||
<SearchX aria-hidden="true" size={26} style={{ color: 'var(--text-disabled)' }} />
|
||||
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
||||
No channels matched — try lowering the minimum items.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectable = proposals.filter((proposal) => !proposal.alreadyExists);
|
||||
const existingCount = proposals.length - selectable.length;
|
||||
const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length;
|
||||
|
||||
const setAll = (rows: AutoTuneProposal[], on: boolean) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
rows.forEach((row) => {
|
||||
if (!row.alreadyExists) {
|
||||
if (on) {
|
||||
next.add(proposalKey(row));
|
||||
} else {
|
||||
next.delete(proposalKey(row));
|
||||
}
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const groups = AXES.map((axis) => ({ axis, rows: proposals.filter((proposal) => proposal.axis === axis.id) })).filter(
|
||||
(entry) => entry.rows.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 920, margin: '0 auto', padding: '20px 24px 40px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
|
||||
<span style={{ ...monoStyle, color: 'var(--text-primary)', fontWeight: 600 }}>{selectedCount}</span> of{' '}
|
||||
{selectable.length} selected
|
||||
</span>
|
||||
{existingCount > 0 && (
|
||||
<Tag icon={<CheckCircle2 aria-hidden="true" size={11} />} tone="neutral">
|
||||
{existingCount} already exist — deselected
|
||||
</Tag>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.map(({ axis, rows }) => {
|
||||
const groupSelectable = rows.filter((row) => !row.alreadyExists);
|
||||
const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(proposalKey(row)));
|
||||
const someOn = groupSelectable.some((row) => selected.has(proposalKey(row)));
|
||||
const AxisIcon = axis.icon;
|
||||
const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
|
||||
return (
|
||||
<section
|
||||
key={axis.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-hairline)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface-card)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border-hairline)',
|
||||
background: 'var(--ctv-bg-sunken)'
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allOn}
|
||||
indeterminate={someOn && !allOn}
|
||||
disabled={groupSelectable.length === 0}
|
||||
label={`Select all ${axis.title}`}
|
||||
onChange={() => setAll(rows, !allOn)}
|
||||
/>
|
||||
<AxisIcon aria-hidden="true" size={15} />
|
||||
<span style={{ font: 'var(--weight-semibold) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
|
||||
{axis.title}
|
||||
</span>
|
||||
<Badge tone="neutral">{rows.length}</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...eyebrowStyle, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<OrderIcon aria-hidden="true" size={11} />
|
||||
{axis.order}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((row, i) => {
|
||||
const on = selected.has(proposalKey(row));
|
||||
return (
|
||||
<div
|
||||
key={proposalKey(row)}
|
||||
style={{
|
||||
borderTop: i ? '1px solid var(--border-hairline)' : 'none',
|
||||
opacity: row.alreadyExists ? 0.55 : 1
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 14px' }}>
|
||||
<Checkbox
|
||||
checked={on && !row.alreadyExists}
|
||||
disabled={row.alreadyExists}
|
||||
label={row.name}
|
||||
onChange={() => toggle(proposalKey(row))}
|
||||
/>
|
||||
<span style={{ ...monoStyle, minWidth: 42, font: 'var(--text-sm) var(--font-mono)', color: 'var(--text-secondary)' }}>
|
||||
{row.number}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)',
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{row.name}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
||||
from “{row.value}”
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="neutral">
|
||||
<span style={monoStyle}>{row.itemCount}</span> items
|
||||
</Badge>
|
||||
{row.alreadyExists && <Tag tone="neutral">Exists</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateStep({ state, group, onRetry }: { state: CreateState; group: string; onRetry: () => void }) {
|
||||
if (state.status === 'loading' || state.status === 'idle') {
|
||||
return (
|
||||
<div style={centeredMessageStyle} role="status">
|
||||
<Spinner size={22} />
|
||||
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Creating channels…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div style={centeredMessageStyle} role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
|
||||
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
|
||||
<Button size="sm" variant="secondary" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = state;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
|
||||
<Stat label="Created" value={result.createdCount} icon={<CheckCircle2 aria-hidden="true" size={16} />} />
|
||||
<Stat label="Skipped" value={result.skippedCount} icon={<MinusCircle aria-hidden="true" size={16} />} />
|
||||
<Stat label="Failed" value={result.failedCount} icon={<XCircle aria-hidden="true" size={16} />} />
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 7, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<FolderTree aria-hidden="true" size={13} /> Added to group{' '}
|
||||
<span style={{ font: 'var(--weight-semibold) var(--text-xs) var(--font-sans)', color: 'var(--text-primary)' }}>{group}</span>
|
||||
</div>
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid var(--border-hairline)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface-card)'
|
||||
}}
|
||||
>
|
||||
{result.results.map((entry, i) => {
|
||||
const meta = RESULT_META[entry.status] ?? RESULT_META.Skipped;
|
||||
const StatusIcon = meta.icon;
|
||||
return (
|
||||
<div
|
||||
key={`${entry.name}-${i}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '11px 14px',
|
||||
borderTop: i ? '1px solid var(--border-hairline)' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{ color: meta.color, display: 'inline-flex' }}>
|
||||
<StatusIcon aria-hidden="true" size={17} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
|
||||
{entry.name}
|
||||
</div>
|
||||
{entry.reason && (
|
||||
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
||||
{entry.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.channelId != null && (
|
||||
<span style={{ ...monoStyle, font: 'var(--text-xs) var(--font-mono)', color: 'var(--text-disabled)' }}>
|
||||
#{entry.channelId}
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={meta.tone}>{entry.status}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const centeredMessageStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
gap: 12,
|
||||
padding: '40px 24px'
|
||||
};
|
||||
Reference in New Issue
Block a user