fix(web): Channel Builder review fixes (refs #89)

- Dialog: focus only on open transition (was stealing focus from its own
  inputs every parent re-render); Escape listener via latest-ref
- Advanced overrides: honest 'Inherit from template' semantics - the
  handler coalesces null with the template value so 'None' could never
  clear anything (clear-to-none follow-up: #135); save-as-template now
  composes from the same effective-value helper
- toggle rows: label->div (label activation forwarding double-fired in
  real browsers); clear stale 422 row highlight on lineup mutations
- port .ctv-lift/.ctv-press motion utilities + apply per prototype;
  Advanced expand scroll-into-view; Info glyph on advanced info line
- fixture fidelity: real 'Validation failed' title, verbatim handler
  detail message, omit null keys, collectionKind on rerun fixture
- cache successful artwork upload across create retries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 23:22:52 +02:00
co-authored by Claude Fable 5
parent 579b30d489
commit 54a0760fcd
4 changed files with 309 additions and 140 deletions
+19 -18
View File
@@ -2203,7 +2203,7 @@ describe('ChicoryTV SPA scaffold', () => {
...builderDefaults(),
browseItems: [
browseItem(),
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', rerunCollectionId: 9, mediaItemId: undefined })
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', collectionKind: 'Rerun', rerunCollectionId: 9, mediaItemId: undefined })
]
});
@@ -2276,7 +2276,9 @@ describe('ChicoryTV SPA scaffold', () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })],
fromLineupFailure: { detail: 'lineup[1] references a missing media item.', status: 422, title: 'Invalid lineup' }
// Verbatim wire shape: ApiResults hard-codes the 422 title "Validation failed",
// and the handler's not-found detail is "lineup[i] <Label> <id> does not exist."
fromLineupFailure: { detail: 'lineup[1] TelevisionShow 999 does not exist.', status: 422, title: 'Validation failed' }
});
const { container } = await renderBuilder();
@@ -2287,7 +2289,7 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(createBtn());
expect(await screen.findByText('lineup[1] references a missing media item.')).toBeInTheDocument();
expect(await screen.findByText('lineup[1] TelevisionShow 999 does not exist.')).toBeInTheDocument();
await waitFor(() => {
const rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true);
@@ -2392,6 +2394,13 @@ function mediaSource(overrides: Record<string, unknown> = {}): Record<string, un
}
// ---- Channel Builder (#89) fixtures ----------------------------------------
// The API serializes with Newtonsoft NullValueHandling.Ignore: null-valued
// members are omitted from the wire entirely. Fixtures mirror that by
// stripping null/undefined keys after overrides are applied.
function omitNullKeys(fixture: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(fixture).filter(([, value]) => value != null));
}
// ChannelResponseModel
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
@@ -2410,46 +2419,38 @@ function channelSummary(overrides: Record<string, unknown> = {}): Record<string,
};
}
// ChannelTemplateResponseModel
// ChannelTemplateResponseModel — nullable members (filler/watermark ids,
// preferred languages, streamSelector, musicVideoCreditsTemplate) are null on
// the Standard template and therefore absent from the serialized payload.
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
return omitNullKeys({
description: 'General-purpose 1080p H.264.',
fFmpegProfileId: 100,
fallbackFillerId: null,
fixedStartTimeBehavior: 'Strict',
id: 10,
idleBehavior: 'StopOnDisconnect',
isDefault: true,
isSystem: true,
midRollFillerId: null,
musicVideoCreditsMode: 'None',
musicVideoCreditsTemplate: null,
name: 'Standard',
playoutMode: 'Continuous',
playoutSource: 'Generated',
postRollFillerId: null,
preRollFillerId: null,
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
randomStartPoint: false,
shuffleScheduleItems: false,
songVideoMode: 'Default',
streamSelector: null,
streamSelectorMode: 'Default',
streamingMode: 'HttpLiveStreamingSegmenter',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
watermarkId: null,
...overrides
};
});
}
// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one
// typed id is populated and nullable metadata (duration/itemCount) is absent
// unless the caller supplies it.
function browseItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
return omitNullKeys({
artwork: '',
collectionType: 'TelevisionShow',
id: 1,
@@ -2459,7 +2460,7 @@ function browseItem(overrides: Record<string, unknown> = {}): Record<string, unk
mediaType: 'TelevisionShow',
title: 'Looney Tunes',
...overrides
};
});
}
// FFmpegFullProfileResponseModel (only id/name are read by the builder)
+229 -113
View File
@@ -19,6 +19,7 @@ import {
FolderTree,
GripVertical,
Image as ImageIcon,
Info,
Layers,
Library,
LayoutGrid,
@@ -289,7 +290,7 @@ function Segmented<T extends string>({
type="button"
aria-pressed={on}
title={option.title ?? option.label}
className={`ctv-builder-seg-btn${on ? ' ctv-builder-seg-btn-on' : ''}`}
className={`ctv-builder-seg-btn ctv-press${on ? ' ctv-builder-seg-btn-on' : ''}`}
onClick={() => onChange(option.value)}
>
{option.icon}
@@ -368,7 +369,7 @@ function LibCard({
onDragEnd={onDragEnd}
onDoubleClick={onAdd}
title="Double-click or drag to add"
className="ctv-builder-libcard-compact"
className="ctv-builder-libcard-compact ctv-press"
style={
added
? {
@@ -409,7 +410,7 @@ function LibCard({
onDragEnd={onDragEnd}
onDoubleClick={onAdd}
title="Double-click or drag to add"
className="ctv-builder-libcard"
className="ctv-builder-libcard ctv-lift"
>
<div
className="ctv-builder-libcard-poster"
@@ -566,11 +567,10 @@ function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides:
return (template as unknown as Record<string, unknown>)[key];
}
const NONE_OPTION = { value: '', label: 'None' };
function toIdOverride(raw: string): number | null {
return raw ? Number(raw) : null;
}
// Sentinel select value meaning "inherit from the template" — the field is
// omitted from `advanced` entirely (not sent as null). The API cannot express
// clear-to-none, so there is no "None" choice; see #135.
const INHERIT = '__inherit__';
// ---- Main screen -----------------------------------------------------------
interface BuilderDataState {
@@ -727,6 +727,9 @@ function ChannelBuilder({
const [createError, setCreateError] = useState<string | null>(null);
const [errorRowIndex, setErrorRowIndex] = useState<number | null>(null);
const creatingRef = useRef(false);
// Cache a successful artwork upload keyed by the File object, so retrying a
// failed create with the same image doesn't orphan a fresh upload each attempt.
const uploadedLogoRef = useRef<{ file: File; logo: CreateChannelFromLineupRequest['logo'] } | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
@@ -751,16 +754,26 @@ function ChannelBuilder({
setTplOpen(false);
};
// Any lineup mutation invalidates a server row-index highlight (it pointed at
// the pre-mutation array), so clear the stale create error whenever it changes.
const clearLineupError = () => {
setCreateError(null);
setErrorRowIndex(null);
};
const addItem = (item: LibraryBrowseItem) => {
clearLineupError();
setLineup((current) => (current.some((existing) => lineupKey(existing) === lineupKey(item)) ? current : [...current, item]));
};
const removeAt = (index: number) => {
clearLineupError();
setLineup((current) => current.filter((_, position) => position !== index));
};
// drop onto lineup container (append) or a row (insert at index)
const dropAt = (targetIndex: number | null) => {
clearLineupError();
setLineup((current) => {
if (dragRow != null) {
const next = current.slice();
@@ -871,10 +884,18 @@ function ChannelBuilder({
try {
let logo: CreateChannelFromLineupRequest['logo'] = { path: '', contentType: '' };
if (logoFile) {
const uploaded = await uploadArtwork(logoFile, 'logo');
logo = { path: uploaded.path, contentType: uploaded.contentType };
if (uploadedLogoRef.current?.file === logoFile) {
logo = uploadedLogoRef.current.logo;
} else {
const uploaded = await uploadArtwork(logoFile, 'logo');
logo = { path: uploaded.path, contentType: uploaded.contentType };
uploadedLogoRef.current = { file: logoFile, logo };
}
}
// advanced null/omitted = inherit template value (handler coalesces with ??);
// clear-to-none is not expressible — see #135. Overrides only ever holds
// real values (inherited fields are absent), so we copy present keys as-is.
const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode };
for (const key of ADVANCED_KEYS) {
if (key in overrides) {
@@ -944,37 +965,39 @@ function ChannelBuilder({
setSaveBusy(true);
setSaveError(null);
const resolved = <T,>(key: AdvancedKey, fallback: T): T =>
(key in overrides ? (overrides[key] as T) : ((template as unknown as Record<string, unknown>)[key] as T)) ?? fallback;
// Compose from the same effective-value helper the panel renders with, so
// what's saved equals what's shown. Overrides never hold null/'' (inherited
// fields are absent), so effectiveValue is exactly the displayed value.
const eff = <T,>(key: AdvancedKey): T => effectiveValue(key, template, overrides) as T;
try {
const requestBody: CreateChannelTemplateRequest = {
name: saveName.trim(),
description: saveDesc.trim(),
fFmpegProfileId: resolved('fFmpegProfileId', template.fFmpegProfileId),
watermarkId: resolved('watermarkId', template.watermarkId),
fallbackFillerId: resolved('fallbackFillerId', template.fallbackFillerId),
preRollFillerId: resolved('preRollFillerId', template.preRollFillerId),
midRollFillerId: resolved('midRollFillerId', template.midRollFillerId),
postRollFillerId: resolved('postRollFillerId', template.postRollFillerId),
streamSelectorMode: resolved('streamSelectorMode', template.streamSelectorMode),
streamSelector: resolved('streamSelector', template.streamSelector),
preferredAudioLanguageCode: resolved('preferredAudioLanguageCode', template.preferredAudioLanguageCode),
preferredAudioTitle: resolved('preferredAudioTitle', template.preferredAudioTitle),
fFmpegProfileId: eff('fFmpegProfileId'),
watermarkId: eff('watermarkId'),
fallbackFillerId: eff('fallbackFillerId'),
preRollFillerId: eff('preRollFillerId'),
midRollFillerId: eff('midRollFillerId'),
postRollFillerId: eff('postRollFillerId'),
streamSelectorMode: eff('streamSelectorMode'),
streamSelector: eff('streamSelector'),
preferredAudioLanguageCode: eff('preferredAudioLanguageCode'),
preferredAudioTitle: eff('preferredAudioTitle'),
playoutSource: template.playoutSource,
// toggles are the source of truth for these two:
playoutMode,
streamingMode: resolved('streamingMode', template.streamingMode),
preferredSubtitleLanguageCode: resolved('preferredSubtitleLanguageCode', template.preferredSubtitleLanguageCode),
subtitleMode: resolved('subtitleMode', template.subtitleMode),
musicVideoCreditsMode: resolved('musicVideoCreditsMode', template.musicVideoCreditsMode),
streamingMode: eff('streamingMode'),
preferredSubtitleLanguageCode: eff('preferredSubtitleLanguageCode'),
subtitleMode: eff('subtitleMode'),
musicVideoCreditsMode: eff('musicVideoCreditsMode'),
musicVideoCreditsTemplate: template.musicVideoCreditsTemplate,
songVideoMode: resolved('songVideoMode', template.songVideoMode),
transcodeMode: resolved('transcodeMode', template.transcodeMode),
idleBehavior: resolved('idleBehavior', template.idleBehavior),
songVideoMode: eff('songVideoMode'),
transcodeMode: eff('transcodeMode'),
idleBehavior: eff('idleBehavior'),
shuffleScheduleItems: shuffleOn,
randomStartPoint: resolved('randomStartPoint', template.randomStartPoint),
fixedStartTimeBehavior: resolved('fixedStartTimeBehavior', template.fixedStartTimeBehavior)
randomStartPoint: eff('randomStartPoint'),
fixedStartTimeBehavior: eff('fixedStartTimeBehavior')
};
const created = await createChannelTemplate(requestBody);
@@ -1082,7 +1105,7 @@ function ChannelBuilder({
<div className="ctv-builder-chiprow">
<button
type="button"
className={`ctv-builder-chip${libraryId == null ? ' ctv-builder-chip-on' : ''}`}
className={`ctv-builder-chip ctv-press${libraryId == null ? ' ctv-builder-chip-on' : ''}`}
onClick={() => setLibraryId(null)}
>
All
@@ -1091,7 +1114,7 @@ function ChannelBuilder({
<button
key={chip.id}
type="button"
className={`ctv-builder-chip${libraryId === chip.id ? ' ctv-builder-chip-on' : ''}`}
className={`ctv-builder-chip ctv-press${libraryId === chip.id ? ' ctv-builder-chip-on' : ''}`}
onClick={() => setLibraryId(chip.id)}
>
{chip.name}
@@ -1200,7 +1223,7 @@ function ChannelBuilder({
return (
<div
key={`${lineupKey(item)}-${index}`}
className={`ctv-builder-lineup-row${rowError || serverError ? ' ctv-builder-lineup-row-error' : ''}`}
className={`ctv-builder-lineup-row ctv-lift${rowError || serverError ? ' ctv-builder-lineup-row-error' : ''}`}
style={{
opacity: dragRow === index ? 0.35 : 1,
boxShadow: overRow === index && dragRow !== index ? '0 -2px 0 var(--action-primary)' : 'none'
@@ -1392,7 +1415,10 @@ function ChannelBuilder({
{/* friendly toggles */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label
{/* div, not label: the Switch is a labelable <button>, so a wrapping
label would forward its activation AND bubble the click, firing
the toggle twice. The row's onClick is the single toggle source. */}
<div
className="ctv-builder-toggle-row"
onClick={() => setPlaybackOrder(shuffleOn ? 'Chronological' : 'Shuffle')}
>
@@ -1409,9 +1435,9 @@ function ChannelBuilder({
</div>
{/* presentational - the row's onClick is the single toggle source */}
<Switch checked={shuffleOn} />
</label>
</div>
<div className="ctv-builder-hairline" />
<label
<div
className={`ctv-builder-toggle-row ctv-builder-toggle-live${alwaysOn ? ' ctv-builder-toggle-live-on' : ''}`}
onClick={() => setPlayoutMode(alwaysOn ? 'OnDemand' : 'Continuous')}
>
@@ -1437,7 +1463,7 @@ function ChannelBuilder({
</div>
{/* presentational - the row's onClick is the single toggle source */}
<Switch checked={alwaysOn} />
</label>
</div>
</div>
{/* channel template */}
@@ -1452,7 +1478,7 @@ function ChannelBuilder({
</div>
) : (
<>
<button type="button" className="ctv-builder-tpl-button" onClick={() => setTplOpen((open) => !open)}>
<button type="button" className="ctv-builder-tpl-button ctv-press" onClick={() => setTplOpen((open) => !open)}>
<span className="ctv-builder-tpl-tile">
<SlidersHorizontal size={15} aria-hidden="true" />
</span>
@@ -1476,7 +1502,7 @@ function ChannelBuilder({
<button
key={option.id}
type="button"
className={`ctv-builder-tpl-option${on ? ' ctv-builder-tpl-option-on' : ''}`}
className={`ctv-builder-tpl-option ctv-press${on ? ' ctv-builder-tpl-option-on' : ''}`}
onClick={() => applyTemplate(option)}
>
{on ? (
@@ -1536,6 +1562,7 @@ function ChannelBuilder({
setMode={setAdvMode}
maxHeight={advMaxHeight}
fieldsRef={advFieldsRef}
scrollerRef={railScrollRef}
ffmpegProfiles={data.ffmpegProfiles}
fillerPresets={data.fillerPresets}
watermarks={data.watermarks}
@@ -1552,6 +1579,7 @@ function ChannelBuilder({
confirmLabel="Clear lineup"
tone="danger"
onConfirm={() => {
clearLineupError();
setLineup([]);
setConfirmClear(false);
}}
@@ -1652,6 +1680,7 @@ function AdvancedPanel({
setMode,
maxHeight,
fieldsRef,
scrollerRef,
ffmpegProfiles,
fillerPresets,
watermarks
@@ -1671,6 +1700,7 @@ function AdvancedPanel({
setMode: (next: 'closed' | 'view' | 'override') => void;
maxHeight: number;
fieldsRef: React.RefObject<HTMLDivElement | null>;
scrollerRef: React.RefObject<HTMLDivElement | null>;
ffmpegProfiles: FFmpegProfile[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
@@ -1680,28 +1710,77 @@ function AdvancedPanel({
const fieldCount = ADVANCED_KEYS.length + 2; // + playbackOrder + playoutMode (toggled)
const setOverride = (key: AdvancedKey, value: unknown) => setOverrides((prev) => ({ ...prev, [key]: value }));
const removeOverride = (key: AdvancedKey) =>
setOverrides((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
const idSelectOptions = (rows: Array<{ id: number; name: string | null }>) => [
NONE_OPTION,
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
];
// Override selects: the first option is always "Inherit from template" (the
// INHERIT sentinel -> field omitted from the request). There is deliberately
// NO "None" option for filler/watermark ids: the create handler coalesces
// null with the template value, so clear-to-none is not expressible (#135).
const templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
const inheritOption = (inheritedLabel: string | null) => ({
value: INHERIT,
label: inheritedLabel ? `Inherit (${inheritedLabel})` : 'Inherit from template'
});
const fillerOptions = idSelectOptions(fillerPresets);
const watermarkOptions = idSelectOptions(watermarks);
const ffmpegOptions = ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }));
const idValue = (key: AdvancedKey): string => {
const value = effectiveValue(key, template, overrides);
return value == null ? '' : String(value);
const idSelectOptions = (key: AdvancedKey, rows: Array<{ id: number; name: string | null }>) => {
const inherited = templateValueOf(key) as number | null;
return [
inheritOption(inherited == null ? null : fillerName(rows, inherited)),
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
];
};
const strValue = (key: AdvancedKey): string => {
const value = effectiveValue(key, template, overrides);
return value == null ? '' : String(value);
// Selected value for an override select: the override when present, else INHERIT.
const selValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key]) : INHERIT);
const onSelect = (key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => {
const raw = event.target.value;
if (raw === INHERIT) {
removeOverride(key);
} else {
setOverride(key, map(raw));
}
};
// Text inputs: empty string = inherit (field omitted); never send null or ''.
const textValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key] ?? '') : '');
const onText = (key: AdvancedKey) => (event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
if (raw === '') {
removeOverride(key);
} else {
setOverride(key, raw);
}
};
const textPlaceholder = (key: AdvancedKey, fallback?: string): string | undefined => {
const inherited = templateValueOf(key);
return inherited ? `Inherit: ${String(inherited)}` : fallback;
};
const boolValue = (key: AdvancedKey): boolean => Boolean(effectiveValue(key, template, overrides));
// Opening a panel scrolls the rail so the Advanced header sits near the top
// (ported from the prototype ChannelBuilder.jsx toggle()).
const rootRef = useRef<HTMLDivElement>(null);
const toggleMode = (next: 'view' | 'override') => {
const nextMode = mode === next ? 'closed' : next;
setMode(nextMode);
if (nextMode !== 'closed' && scrollerRef.current && rootRef.current) {
const scroller = scrollerRef.current;
const root = rootRef.current;
requestAnimationFrame(() => {
const target = scroller.scrollTop + root.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 20;
scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
});
}
};
return (
<div className="ctv-builder-adv">
<div className="ctv-builder-adv" ref={rootRef}>
<div className="ctv-builder-adv-head">
<SlidersHorizontal size={16} color="var(--text-secondary)" aria-hidden="true" />
<span className="ctv-builder-adv-head-title">Advanced</span>
@@ -1713,16 +1792,16 @@ function AdvancedPanel({
<div className="ctv-builder-adv-tabs">
<button
type="button"
className={`ctv-builder-adv-tab${override ? ' ctv-builder-adv-tab-on' : ''}`}
onClick={() => setMode(mode === 'override' ? 'closed' : 'override')}
className={`ctv-builder-adv-tab ctv-press${override ? ' ctv-builder-adv-tab-on' : ''}`}
onClick={() => toggleMode('override')}
>
<SquarePen size={14} aria-hidden="true" />
{override ? 'Overriding' : 'Override settings'}
</button>
<button
type="button"
className={`ctv-builder-adv-tab${mode === 'view' ? ' ctv-builder-adv-tab-on' : ''}`}
onClick={() => setMode(mode === 'view' ? 'closed' : 'view')}
className={`ctv-builder-adv-tab ctv-press${mode === 'view' ? ' ctv-builder-adv-tab-on' : ''}`}
onClick={() => toggleMode('view')}
>
<Eye size={14} aria-hidden="true" />
View defaults
@@ -1731,6 +1810,11 @@ function AdvancedPanel({
<div className="ctv-builder-adv-panel" style={{ maxHeight }}>
<div className="ctv-builder-adv-fields" ref={fieldsRef}>
<div className="ctv-builder-adv-info">
<Info
size={13}
color={override ? 'var(--action-primary)' : 'var(--text-disabled)'}
aria-hidden="true"
/>
{override
? 'Editing these overrides the template for this channel only.'
: `Read-only — inherited from the ${template.name} template. Turn on Override to edit.`}
@@ -1742,23 +1826,35 @@ function AdvancedPanel({
<Select
size="sm"
label="FFmpeg profile"
value={idValue('fFmpegProfileId')}
options={ffmpegOptions}
onChange={(event) => setOverride('fFmpegProfileId', Number(event.target.value))}
value={selValue('fFmpegProfileId')}
options={[
inheritOption(
ffmpegProfiles.find((p) => p.id === template.fFmpegProfileId)?.name ??
`#${template.fFmpegProfileId}`
),
...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }))
]}
onChange={onSelect('fFmpegProfileId', Number)}
/>
<Select
size="sm"
label="Streaming mode"
value={strValue('streamingMode')}
options={STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))}
onChange={(event) => setOverride('streamingMode', event.target.value as StreamingMode)}
value={selValue('streamingMode')}
options={[
inheritOption(STREAMING_MODE_LABELS[template.streamingMode as StreamingMode] ?? template.streamingMode),
...STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))
]}
onChange={onSelect('streamingMode', (raw) => raw as StreamingMode)}
/>
<Select
size="sm"
label="Transcode mode"
value={strValue('transcodeMode')}
options={[...TRANSCODE_MODES]}
onChange={(event) => setOverride('transcodeMode', event.target.value as TranscodeMode)}
value={selValue('transcodeMode')}
options={[
inheritOption(template.transcodeMode),
...TRANSCODE_MODES.map((value) => ({ value, label: value }))
]}
onChange={onSelect('transcodeMode', (raw) => raw as TranscodeMode)}
/>
</AdvGroup>
@@ -1766,37 +1862,37 @@ function AdvancedPanel({
<Select
size="sm"
label="Fallback filler"
value={idValue('fallbackFillerId')}
options={fillerOptions}
onChange={(event) => setOverride('fallbackFillerId', toIdOverride(event.target.value))}
value={selValue('fallbackFillerId')}
options={idSelectOptions('fallbackFillerId', fillerPresets)}
onChange={onSelect('fallbackFillerId', Number)}
/>
<Select
size="sm"
label="Pre-roll filler"
value={idValue('preRollFillerId')}
options={fillerOptions}
onChange={(event) => setOverride('preRollFillerId', toIdOverride(event.target.value))}
value={selValue('preRollFillerId')}
options={idSelectOptions('preRollFillerId', fillerPresets)}
onChange={onSelect('preRollFillerId', Number)}
/>
<Select
size="sm"
label="Mid-roll filler"
value={idValue('midRollFillerId')}
options={fillerOptions}
onChange={(event) => setOverride('midRollFillerId', toIdOverride(event.target.value))}
value={selValue('midRollFillerId')}
options={idSelectOptions('midRollFillerId', fillerPresets)}
onChange={onSelect('midRollFillerId', Number)}
/>
<Select
size="sm"
label="Post-roll filler"
value={idValue('postRollFillerId')}
options={fillerOptions}
onChange={(event) => setOverride('postRollFillerId', toIdOverride(event.target.value))}
value={selValue('postRollFillerId')}
options={idSelectOptions('postRollFillerId', fillerPresets)}
onChange={onSelect('postRollFillerId', Number)}
/>
<Select
size="sm"
label="Watermark"
value={idValue('watermarkId')}
options={watermarkOptions}
onChange={(event) => setOverride('watermarkId', toIdOverride(event.target.value))}
value={selValue('watermarkId')}
options={idSelectOptions('watermarkId', watermarks)}
onChange={onSelect('watermarkId', Number)}
/>
</AdvGroup>
@@ -1811,42 +1907,50 @@ function AdvancedPanel({
<Input
size="sm"
label="Preferred audio language"
value={strValue('preferredAudioLanguageCode')}
onChange={(event) => setOverride('preferredAudioLanguageCode', event.target.value || null)}
placeholder="e.g. eng"
value={textValue('preferredAudioLanguageCode')}
onChange={onText('preferredAudioLanguageCode')}
placeholder={textPlaceholder('preferredAudioLanguageCode', 'e.g. eng')}
/>
<Input
size="sm"
label="Preferred subtitle language"
value={strValue('preferredSubtitleLanguageCode')}
onChange={(event) => setOverride('preferredSubtitleLanguageCode', event.target.value || null)}
placeholder="e.g. eng"
value={textValue('preferredSubtitleLanguageCode')}
onChange={onText('preferredSubtitleLanguageCode')}
placeholder={textPlaceholder('preferredSubtitleLanguageCode', 'e.g. eng')}
/>
<Input
size="sm"
label="Preferred audio title"
value={strValue('preferredAudioTitle')}
onChange={(event) => setOverride('preferredAudioTitle', event.target.value || null)}
value={textValue('preferredAudioTitle')}
onChange={onText('preferredAudioTitle')}
placeholder={textPlaceholder('preferredAudioTitle')}
/>
<Select
size="sm"
label="Subtitle mode"
value={strValue('subtitleMode')}
options={[...SUBTITLE_MODES]}
onChange={(event) => setOverride('subtitleMode', event.target.value as SubtitleMode)}
value={selValue('subtitleMode')}
options={[
inheritOption(template.subtitleMode),
...SUBTITLE_MODES.map((value) => ({ value, label: value }))
]}
onChange={onSelect('subtitleMode', (raw) => raw as SubtitleMode)}
/>
<Select
size="sm"
label="Stream selector mode"
value={strValue('streamSelectorMode')}
options={[...STREAM_SELECTOR_MODES]}
onChange={(event) => setOverride('streamSelectorMode', event.target.value as StreamSelectorMode)}
value={selValue('streamSelectorMode')}
options={[
inheritOption(template.streamSelectorMode),
...STREAM_SELECTOR_MODES.map((value) => ({ value, label: value }))
]}
onChange={onSelect('streamSelectorMode', (raw) => raw as StreamSelectorMode)}
/>
<Input
size="sm"
label="Stream selector"
value={strValue('streamSelector')}
onChange={(event) => setOverride('streamSelector', event.target.value || null)}
value={textValue('streamSelector')}
onChange={onText('streamSelector')}
placeholder={textPlaceholder('streamSelector')}
/>
</AdvGroup>
@@ -1861,30 +1965,42 @@ function AdvancedPanel({
<Select
size="sm"
label="Music video credits"
value={strValue('musicVideoCreditsMode')}
options={[...MUSIC_VIDEO_CREDITS_MODES]}
onChange={(event) => setOverride('musicVideoCreditsMode', event.target.value as MusicVideoCreditsMode)}
value={selValue('musicVideoCreditsMode')}
options={[
inheritOption(template.musicVideoCreditsMode),
...MUSIC_VIDEO_CREDITS_MODES.map((value) => ({ value, label: value }))
]}
onChange={onSelect('musicVideoCreditsMode', (raw) => raw as MusicVideoCreditsMode)}
/>
<Select
size="sm"
label="Song video mode"
value={strValue('songVideoMode')}
options={[...SONG_VIDEO_MODES]}
onChange={(event) => setOverride('songVideoMode', event.target.value as SongVideoMode)}
value={selValue('songVideoMode')}
options={[
inheritOption(template.songVideoMode),
...SONG_VIDEO_MODES.map((value) => ({ value, label: value }))
]}
onChange={onSelect('songVideoMode', (raw) => raw as SongVideoMode)}
/>
<Select
size="sm"
label="Idle behavior"
value={strValue('idleBehavior')}
options={[...IDLE_BEHAVIORS]}
onChange={(event) => setOverride('idleBehavior', event.target.value as IdleBehavior)}
value={selValue('idleBehavior')}
options={[
inheritOption(template.idleBehavior),
...IDLE_BEHAVIORS.map((value) => ({ value, label: value }))
]}
onChange={onSelect('idleBehavior', (raw) => raw as IdleBehavior)}
/>
<Select
size="sm"
label="Fixed start time"
value={strValue('fixedStartTimeBehavior')}
options={[...FIXED_START_TIME_BEHAVIORS]}
onChange={(event) => setOverride('fixedStartTimeBehavior', event.target.value as FixedStartTimeBehavior)}
value={selValue('fixedStartTimeBehavior')}
options={[
inheritOption(template.fixedStartTimeBehavior),
...FIXED_START_TIME_BEHAVIORS.map((value) => ({ value, label: value }))
]}
onChange={onSelect('fixedStartTimeBehavior', (raw) => raw as FixedStartTimeBehavior)}
/>
<div className="ctv-builder-adv-switchrow">
<span>Random start point</span>
@@ -1920,8 +2036,8 @@ function AdvancedPanel({
</AdvGroup>
<AdvGroup label="Playback">
<ReadOnlyField label="Playback order" value={playbackOrder} />
<ReadOnlyField label="Preferred audio language" value={template.preferredAudioLanguageCode ?? 'None'} />
<ReadOnlyField label="Preferred subtitle language" value={template.preferredSubtitleLanguageCode ?? 'None'} />
<ReadOnlyField label="Preferred audio language" value={template.preferredAudioLanguageCode ?? 'Not set'} />
<ReadOnlyField label="Preferred subtitle language" value={template.preferredSubtitleLanguageCode ?? 'Not set'} />
<ReadOnlyField label="Subtitle mode" value={template.subtitleMode} />
<ReadOnlyField label="Stream selector mode" value={template.streamSelectorMode} />
</AdvGroup>
+24 -3
View File
@@ -17,6 +17,16 @@ export function Dialog({ open, onClose, title, children, footer, width, style }:
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
// Latest-ref for onClose so the Escape listener never has to re-subscribe when
// the parent re-renders with a fresh onClose identity.
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
});
// Focus the panel + lock body scroll only on the closed->open transition.
// (Keyed on `open` alone — depending on onClose here would steal focus from
// inputs on every keystroke, since parents hand us a new onClose each render.)
useEffect(() => {
if (!open) {
return;
@@ -27,18 +37,29 @@ export function Dialog({ open, onClose, title, children, footer, width, style }:
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
// Escape-to-close listener; reads the latest onClose via ref so it too only
// depends on `open`.
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
onCloseRef.current();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', onKeyDown);
};
}, [open, onClose]);
}, [open]);
if (!open) {
return null;
+37 -6
View File
@@ -2380,14 +2380,10 @@
color: var(--text-secondary);
}
/* hover lift comes from the shared .ctv-lift utility */
.ctv-builder-libcard {
position: relative;
cursor: grab;
transition: transform var(--dur-fast) var(--ease-standard, ease);
}
.ctv-builder-libcard:hover {
transform: translateY(-2px);
}
.ctv-builder-libcard-poster {
@@ -2550,7 +2546,7 @@
.ctv-builder-auto-chip {
font: 9px / 1 var(--font-mono);
padding: 2px 5px;
border-radius: 3px;
border-radius: var(--radius-xs);
background: var(--ctv-accent-soft);
color: var(--ctv-accent);
}
@@ -2932,15 +2928,50 @@
border: 1px solid var(--border-hairline);
}
/* Shared motion utilities — ported from the prototype (chicorytv-admin/app.html).
Subtle, iOS/macOS-style: quick, ease-out, small. */
.ctv-lift {
transition:
transform 240ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ctv-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.ctv-press {
transition:
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
background 220ms cubic-bezier(0.16, 1, 0.3, 1),
color 220ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ctv-press:active {
transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.ctv-lift,
.ctv-press,
.ctv-builder-seg-thumb,
.ctv-builder-seg-btn,
.ctv-builder-adv-panel,
.ctv-builder-adv-tab,
.ctv-builder-lineup-row,
.ctv-builder-libcard,
.ctv-builder-toggle-live,
.ctv-builder-added-check {
transition: none;
animation: none;
}
.ctv-lift:hover,
.ctv-press:active {
transform: none;
}
}
@media (max-width: 980px) {