Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m16s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold adversarial review (no blockers, 3 real Mediums): - Query&size "Order" row ignored the Shuffle toggle (dead ?? fallback showing the static axis order); now reflects shuffleOn, consistent with the subtitle. - Clearing Name/Number to '' flagged the row Edited + armed the unsaved-changes guard, but the payload reverted to the proposal default — the two "edited" derivations disagreed. overrideEdited now ignores an empty (inherited) value. - runPreview did not clear per-channel overrides, so edits (incl. pinned numbers) silently carried into a re-generated batch → collision risk. Fresh preview now resets overrides/detailKey/logo cache. Tests: empty-name-not-Edited + re-preview-clears-overrides. Live-E2E (local instance, seeded TV library): preview → Configure → toggle Shuffle → Create verified end-to-end; the created channel's schedule shows PlaybackOrder=Shuffle (overridden) vs SeasonEpisode (axis default), confirming the per-channel override flows UI → SPA → backend → playout. Refs #386
1574 lines
63 KiB
TypeScript
1574 lines
63 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
CheckCircle2,
|
|
Circle,
|
|
Clapperboard,
|
|
Eye,
|
|
Film,
|
|
FolderTree,
|
|
Hash,
|
|
Image as ImageIcon,
|
|
Info,
|
|
ListOrdered,
|
|
MinusCircle,
|
|
Pencil,
|
|
RotateCcw,
|
|
SearchX,
|
|
Settings2,
|
|
Shuffle,
|
|
Sparkles,
|
|
TriangleAlert,
|
|
Tv,
|
|
XCircle
|
|
} from 'lucide-react';
|
|
import { Badge, Button, ChannelLogo, Checkbox, Input, Select, SlideOver, Spinner, Stat, Switch, Tag, Tooltip } from '../components';
|
|
import {
|
|
createAutoTunedChannels,
|
|
getAutoTuneChannelMembers,
|
|
getChannelTemplates,
|
|
getDefaultChannelTemplate,
|
|
getFFmpegProfiles,
|
|
getFillerPresets,
|
|
getWatermarks,
|
|
messageFromAutoTuneError,
|
|
messageFromChannelTemplateError,
|
|
uploadArtwork,
|
|
previewAutoTune,
|
|
type AutoTuneAxis,
|
|
type AutoTunedChannelRequest,
|
|
type AutoTuneMember,
|
|
type AutoTuneProposal,
|
|
type AutoTuneResult,
|
|
type ChannelTemplate,
|
|
type FFmpegProfile,
|
|
type FillerPreset,
|
|
type Watermark
|
|
} from '../api';
|
|
import { registerNavigationGuard } from '../navigationGuard';
|
|
import {
|
|
FIXED_START_TIME_BEHAVIORS,
|
|
IDLE_BEHAVIORS,
|
|
MUSIC_VIDEO_CREDITS_MODES,
|
|
SONG_VIDEO_MODES,
|
|
STREAM_SELECTOR_MODES,
|
|
STREAMING_MODE_LABELS,
|
|
STREAMING_MODES,
|
|
SUBTITLE_MODES,
|
|
TRANSCODE_MODES,
|
|
effectiveValue,
|
|
inheritOption,
|
|
templatePlaybackOrder,
|
|
useAdvancedOverrides,
|
|
type Overrides,
|
|
type PlaybackOrder,
|
|
type PlayoutMode,
|
|
type StreamingMode
|
|
} from '../builder/advancedOptions';
|
|
|
|
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}`;
|
|
|
|
// A per-channel edit accumulated in the Preview step's DetailPanel, keyed by proposalKey.
|
|
// Everything is optional/inherited: an unset field falls back to the proposal default or the
|
|
// (per-channel-or-batch) template. playbackOrder + playoutMode mirror the Channel Builder's
|
|
// dedicated-state model (see builder/advancedOptions ADVANCED_KEYS comment) — the Shuffle and
|
|
// Always-playing toggles — and are merged into `advanced` only when the user diverges from the
|
|
// template default. Per-source rotation weights + query corrections are #425, not modelled here.
|
|
interface ChannelOverride {
|
|
name?: string;
|
|
number?: string;
|
|
templateId?: string; // '' / undefined = inherit the batch template
|
|
logoFile?: File;
|
|
logoPreview?: string;
|
|
playbackOrder?: PlaybackOrder;
|
|
playoutMode?: PlayoutMode;
|
|
advanced: Overrides;
|
|
}
|
|
|
|
const emptyOverride = (): ChannelOverride => ({ advanced: {} });
|
|
|
|
// True when the user actually diverged this proposal from its defaults (drives the "Edited"
|
|
// row badge + the unsaved-changes guard). A name/number equal to the proposal default does
|
|
// not count as edited.
|
|
function overrideEdited(override: ChannelOverride | undefined, proposal: AutoTuneProposal): boolean {
|
|
if (!override) {
|
|
return false;
|
|
}
|
|
return Boolean(
|
|
// A cleared field (trimmed to '') is NOT an edit — it inherits the proposal default, the same
|
|
// value buildChannels submits (`override.name?.trim() || proposal.name`). Only a non-empty value
|
|
// that differs counts, so the "Edited" badge + dirty guard match what actually gets created.
|
|
(override.name != null && override.name.trim() !== '' && override.name.trim() !== proposal.name) ||
|
|
(override.number != null && override.number.trim() !== '' && override.number.trim() !== proposal.number) ||
|
|
override.templateId ||
|
|
override.logoFile ||
|
|
override.playbackOrder != null ||
|
|
override.playoutMode != null ||
|
|
Object.keys(override.advanced).length > 0
|
|
);
|
|
}
|
|
|
|
type TemplatesState =
|
|
| { status: 'loading' }
|
|
| { status: 'error'; message: string }
|
|
| {
|
|
status: 'success';
|
|
templates: ChannelTemplate[];
|
|
ffmpegProfiles: FFmpegProfile[];
|
|
fillerPresets: FillerPreset[];
|
|
watermarks: Watermark[];
|
|
};
|
|
|
|
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 [overrides, setOverrides] = useState<Record<string, ChannelOverride>>({});
|
|
const [detailKey, setDetailKey] = useState<string | null>(null);
|
|
|
|
// Cache each per-channel logo upload by File so a create retry doesn't orphan a fresh
|
|
// artwork every attempt (mirrors ChannelBuilder's uploadedLogoRef).
|
|
const uploadedLogosRef = useRef<Map<File, { path: string; contentType: string }>>(new Map());
|
|
|
|
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;
|
|
// Pickers (ffmpeg profiles / filler presets / watermarks) back the DetailPanel's Advanced
|
|
// pane; load them alongside templates so a Configure panel opens without a second spinner.
|
|
Promise.all([
|
|
getChannelTemplates(),
|
|
getDefaultChannelTemplate(),
|
|
getFFmpegProfiles(),
|
|
getFillerPresets(),
|
|
getWatermarks()
|
|
])
|
|
.then(([templates, preferred, ffmpegProfiles, fillerPresets, watermarks]) => {
|
|
if (!activeRef.current || id !== templatesSeqRef.current) {
|
|
return;
|
|
}
|
|
setTemplatesState({ status: 'success', templates, ffmpegProfiles, fillerPresets, watermarks });
|
|
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' });
|
|
// A fresh preview is a clean slate: drop any per-channel edits from a prior batch. They are
|
|
// keyed by axis+value and would silently re-attach to the re-enumerated proposals — including a
|
|
// pinned channel number that no longer fits the server's recomputed numbering (collision risk).
|
|
setOverrides({});
|
|
setDetailKey(null);
|
|
uploadedLogosRef.current = new Map();
|
|
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 patchOverride = useCallback(
|
|
(key: string, delta: Partial<ChannelOverride> | ((current: ChannelOverride) => Partial<ChannelOverride>)) => {
|
|
setOverrides((prev) => {
|
|
const current = prev[key] ?? emptyOverride();
|
|
const resolved = typeof delta === 'function' ? delta(current) : delta;
|
|
return { ...prev, [key]: { ...current, ...resolved } };
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const patchAdvanced = useCallback((key: string, updater: (prev: Overrides) => Overrides) => {
|
|
setOverrides((prev) => {
|
|
const current = prev[key] ?? emptyOverride();
|
|
return { ...prev, [key]: { ...current, advanced: updater(current.advanced) } };
|
|
});
|
|
}, []);
|
|
|
|
// Build the per-channel create payload, uploading any per-channel logo first and folding the
|
|
// Shuffle/Always-playing toggles back into `advanced` (only when diverged from the template).
|
|
const buildChannels = async (chosen: AutoTuneProposal[]): Promise<AutoTunedChannelRequest[]> =>
|
|
Promise.all(
|
|
chosen.map(async (proposal) => {
|
|
const override = overrides[proposalKey(proposal)];
|
|
const channel: AutoTunedChannelRequest = {
|
|
axis: proposal.axis as AutoTuneAxis,
|
|
value: proposal.value,
|
|
name: override?.name?.trim() || proposal.name,
|
|
number: override?.number?.trim() || proposal.number
|
|
};
|
|
if (override?.templateId) {
|
|
channel.templateId = Number(override.templateId);
|
|
}
|
|
if (override?.logoFile) {
|
|
let logo = uploadedLogosRef.current.get(override.logoFile);
|
|
if (!logo) {
|
|
const uploaded = await uploadArtwork(override.logoFile, 'logo');
|
|
logo = { path: uploaded.path, contentType: uploaded.contentType };
|
|
uploadedLogosRef.current.set(override.logoFile, logo);
|
|
}
|
|
channel.logo = logo;
|
|
}
|
|
const advanced: Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode } = {
|
|
...(override?.advanced ?? {})
|
|
};
|
|
if (override?.playbackOrder != null) {
|
|
advanced.playbackOrder = override.playbackOrder;
|
|
}
|
|
if (override?.playoutMode != null) {
|
|
advanced.playoutMode = override.playoutMode;
|
|
}
|
|
if (Object.keys(advanced).length > 0) {
|
|
channel.advanced = advanced as AutoTunedChannelRequest['advanced'];
|
|
}
|
|
return channel;
|
|
})
|
|
);
|
|
|
|
const runCreate = () => {
|
|
if (selectedProposals.length === 0 || templateId === '') {
|
|
return;
|
|
}
|
|
const id = ++createSeqRef.current;
|
|
const chosen = selectedProposals;
|
|
setStep('create');
|
|
setCreateState({ status: 'loading' });
|
|
buildChannels(chosen)
|
|
.then((channels) => createAutoTunedChannels({ templateId: Number(templateId), group, channels }))
|
|
.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());
|
|
setOverrides({});
|
|
setDetailKey(null);
|
|
setStep('configure');
|
|
};
|
|
|
|
// Unsaved-changes guard (spa-conventions §8): per-channel edits live only in this screen's
|
|
// draft state until bulk-create, so leaving the screen (or a full-page unload) with edits that
|
|
// haven't been created yet must confirm first. Closing the DetailPanel keeps edits in state, so
|
|
// only navigation/unload is guarded, not panel close.
|
|
const hasUncreatedEdits =
|
|
createState.status !== 'success' && proposals.some((proposal) => overrideEdited(overrides[proposalKey(proposal)], proposal));
|
|
const dirtyRef = useRef(false);
|
|
useEffect(() => {
|
|
dirtyRef.current = hasUncreatedEdits;
|
|
}, [hasUncreatedEdits]);
|
|
useEffect(
|
|
() =>
|
|
registerNavigationGuard(
|
|
() => !dirtyRef.current || window.confirm('Discard your per-channel edits? They have not been created yet.')
|
|
),
|
|
[]
|
|
);
|
|
useEffect(() => {
|
|
if (!hasUncreatedEdits) {
|
|
return;
|
|
}
|
|
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
};
|
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
}, [hasUncreatedEdits]);
|
|
|
|
const templateOptions =
|
|
templatesState.status === 'success'
|
|
? templatesState.templates.map((template) => ({ value: String(template.id), label: template.name }))
|
|
: [];
|
|
|
|
// Resolve the proposal + effective template backing the open DetailPanel. The per-channel
|
|
// template override wins over the batch template; fall back to the batch/first template.
|
|
const detailProposal = detailKey ? (proposals.find((proposal) => proposalKey(proposal) === detailKey) ?? null) : null;
|
|
const detailOverride = detailKey ? (overrides[detailKey] ?? emptyOverride()) : emptyOverride();
|
|
const detailTemplate =
|
|
templatesState.status === 'success'
|
|
? (templatesState.templates.find((template) => String(template.id) === (detailOverride.templateId || templateId)) ??
|
|
templatesState.templates.find((template) => String(template.id) === templateId) ??
|
|
templatesState.templates[0] ??
|
|
null)
|
|
: null;
|
|
|
|
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}
|
|
overrides={overrides}
|
|
onConfigure={setDetailKey}
|
|
onRetry={runPreview}
|
|
/>
|
|
)}
|
|
{step === 'create' && <CreateStep state={createState} group={group} onRetry={runCreate} />}
|
|
</main>
|
|
|
|
{detailProposal && detailTemplate && templatesState.status === 'success' && (
|
|
<DetailPanel
|
|
key={detailKey ?? undefined}
|
|
proposal={detailProposal}
|
|
override={detailOverride}
|
|
templates={templatesState.templates}
|
|
template={detailTemplate}
|
|
batchTemplateId={templateId}
|
|
ffmpegProfiles={templatesState.ffmpegProfiles}
|
|
fillerPresets={templatesState.fillerPresets}
|
|
watermarks={templatesState.watermarks}
|
|
onPatch={(delta) => detailKey && patchOverride(detailKey, delta)}
|
|
onPatchAdvanced={(updater) => detailKey && patchAdvanced(detailKey, updater)}
|
|
onClose={() => setDetailKey(null)}
|
|
/>
|
|
)}
|
|
</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,
|
|
overrides,
|
|
onConfigure,
|
|
onRetry
|
|
}: {
|
|
state: PreviewState;
|
|
selected: Set<string>;
|
|
setSelected: (updater: (current: Set<string>) => Set<string>) => void;
|
|
overrides: Record<string, ChannelOverride>;
|
|
onConfigure: (key: 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 key = proposalKey(row);
|
|
const on = selected.has(key);
|
|
const displayName = overrides[key]?.name?.trim() || row.name;
|
|
const edited = overrideEdited(overrides[key], row);
|
|
return (
|
|
<div
|
|
key={key}
|
|
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={displayName}
|
|
onChange={() => toggle(key)}
|
|
/>
|
|
<span style={{ ...monoStyle, minWidth: 42, font: 'var(--text-sm) var(--font-mono)', color: 'var(--text-secondary)' }}>
|
|
{overrides[key]?.number?.trim() || 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'
|
|
}}
|
|
>
|
|
{displayName}
|
|
</div>
|
|
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
|
from “{row.value}”
|
|
</div>
|
|
</div>
|
|
{edited && (
|
|
<Tag icon={<Pencil aria-hidden="true" size={11} />} tone="accent">
|
|
Edited
|
|
</Tag>
|
|
)}
|
|
<Badge tone="neutral">
|
|
<span style={monoStyle}>{row.itemCount}</span> items
|
|
</Badge>
|
|
{row.alreadyExists ? (
|
|
<Tag tone="neutral">Exists</Tag>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
startIcon={<Settings2 aria-hidden="true" size={14} />}
|
|
onClick={() => onConfigure(key)}
|
|
>
|
|
Configure
|
|
</Button>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
type MembersState =
|
|
| { status: 'loading' }
|
|
| { status: 'error'; message: string }
|
|
| { status: 'success'; totalCount: number; members: AutoTuneMember[] };
|
|
|
|
// How many distinct members to preview in the Content sources pane. The list is advisory
|
|
// (it shows what the smart collection resolves to) so a single page is enough — no infinite scroll.
|
|
const MEMBER_PREVIEW_SIZE = 50;
|
|
|
|
function DetailSection({ label, children, hint }: { label: string; hint?: ReactNode; children: ReactNode }) {
|
|
return (
|
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 22 }}>
|
|
<div style={eyebrowStyle}>{label}</div>
|
|
{children}
|
|
{hint != null && <div style={{ font: 'var(--text-2xs)/1.4 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
label,
|
|
hint,
|
|
checked,
|
|
overridesTemplate,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
hint: string;
|
|
checked: boolean;
|
|
overridesTemplate: boolean;
|
|
onChange: (next: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<Switch checked={checked} onChange={onChange} label={label} />
|
|
{overridesTemplate && <Tag tone="accent">overrides template</Tag>}
|
|
</div>
|
|
<div style={{ marginTop: 1, font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReadRow({ label, value }: { label: string; value: ReactNode }) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
|
<span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-secondary)' }}>{label}</span>
|
|
<span style={{ font: 'var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-primary)', textAlign: 'right' }}>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailPanel({
|
|
proposal,
|
|
override,
|
|
templates,
|
|
template,
|
|
batchTemplateId,
|
|
ffmpegProfiles,
|
|
fillerPresets,
|
|
watermarks,
|
|
onPatch,
|
|
onPatchAdvanced,
|
|
onClose
|
|
}: {
|
|
proposal: AutoTuneProposal;
|
|
override: ChannelOverride;
|
|
templates: ChannelTemplate[];
|
|
template: ChannelTemplate;
|
|
batchTemplateId: string;
|
|
ffmpegProfiles: FFmpegProfile[];
|
|
fillerPresets: FillerPreset[];
|
|
watermarks: Watermark[];
|
|
onPatch: (delta: Partial<ChannelOverride> | ((current: ChannelOverride) => Partial<ChannelOverride>)) => void;
|
|
onPatchAdvanced: (updater: (prev: Overrides) => Overrides) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [membersState, setMembersState] = useState<MembersState>({ status: 'loading' });
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const activeRef = useRef(true);
|
|
|
|
useEffect(() => {
|
|
activeRef.current = true;
|
|
return () => {
|
|
activeRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// Load the distinct members this proposal's smart collection resolves to (#384). The parent
|
|
// keys the panel by proposalKey, so it remounts per proposal and `membersState` starts at
|
|
// 'loading' each time — the effect never sets state synchronously (spa-conventions §3), only in
|
|
// the fetch callbacks.
|
|
useEffect(() => {
|
|
getAutoTuneChannelMembers({ axis: proposal.axis as AutoTuneAxis, value: proposal.value, pageNum: 0, pageSize: MEMBER_PREVIEW_SIZE })
|
|
.then((page) => {
|
|
if (activeRef.current) {
|
|
setMembersState({ status: 'success', totalCount: page.totalCount, members: page.page ?? [] });
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (activeRef.current) {
|
|
setMembersState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to load content sources') });
|
|
}
|
|
});
|
|
}, [proposal.axis, proposal.value]);
|
|
|
|
const readImage = (file: File | null | undefined) => {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => onPatch({ logoFile: file, logoPreview: typeof reader.result === 'string' ? reader.result : undefined });
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const displayName = override.name?.trim() || proposal.name;
|
|
const displayNumber = override.number?.trim() || proposal.number;
|
|
|
|
const templateShuffle = templatePlaybackOrder(template);
|
|
const shuffleOn = (override.playbackOrder ?? templateShuffle) === 'Shuffle';
|
|
const shuffleOverrides = override.playbackOrder != null && override.playbackOrder !== templateShuffle;
|
|
|
|
const templatePlayout = ((template as unknown as Record<string, unknown>).playoutMode as PlayoutMode) ?? 'Continuous';
|
|
const alwaysOn = (override.playoutMode ?? templatePlayout) === 'Continuous';
|
|
const alwaysOverrides = override.playoutMode != null && override.playoutMode !== templatePlayout;
|
|
|
|
const effectiveStreaming = effectiveValue('streamingMode', template, override.advanced) as StreamingMode | undefined;
|
|
const streamingLabel = effectiveStreaming ? (STREAMING_MODE_LABELS[effectiveStreaming] ?? effectiveStreaming) : '—';
|
|
|
|
const batchTemplateName = templates.find((candidate) => String(candidate.id) === batchTemplateId)?.name ?? 'batch template';
|
|
|
|
const overrideCount = Object.keys(override.advanced).length;
|
|
|
|
return (
|
|
<SlideOver
|
|
open
|
|
onClose={onClose}
|
|
title={displayName}
|
|
subtitle={
|
|
<span style={monoStyle}>
|
|
{displayNumber} · {shuffleOn ? 'Shuffled' : 'In order'}
|
|
</span>
|
|
}
|
|
>
|
|
<DetailSection label="Channel identity">
|
|
<Field label="Name" hint="Shown in the guide and channel list.">
|
|
<Input
|
|
size="sm"
|
|
value={displayName}
|
|
onChange={(event) => onPatch({ name: event.target.value })}
|
|
/>
|
|
</Field>
|
|
<Field label="Channel number" hint="Must be unique; the server skips numbers already taken.">
|
|
<Input
|
|
size="sm"
|
|
value={displayNumber}
|
|
onChange={(event) => onPatch({ number: event.target.value })}
|
|
leadingIcon={<Hash aria-hidden="true" size={14} />}
|
|
/>
|
|
</Field>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
fileInputRef.current?.click();
|
|
}
|
|
}}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: 10,
|
|
borderRadius: 'var(--radius-md)',
|
|
border: '1px dashed var(--border-control)',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
<ChannelLogo name={displayName} src={override.logoPreview ?? null} size={44} radius="var(--radius-sm)" />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
|
|
{override.logoPreview ? 'Channel image set' : 'Drop or choose a channel image'}
|
|
</div>
|
|
<div style={{ marginTop: 3, font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
|
Used as the guide logo and on-screen bug. Optional.
|
|
</div>
|
|
</div>
|
|
<ImageIcon aria-hidden="true" size={16} style={{ color: 'var(--text-disabled)' }} />
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: 'none' }}
|
|
onChange={(event) => readImage(event.target.files && event.target.files[0])}
|
|
/>
|
|
</div>
|
|
</DetailSection>
|
|
|
|
<DetailSection label="Playback">
|
|
<ToggleRow
|
|
label="Shuffle"
|
|
hint="Play this channel's items in a shuffled order instead of chronologically."
|
|
checked={shuffleOn}
|
|
overridesTemplate={shuffleOverrides}
|
|
onChange={(next) => onPatch({ playbackOrder: next ? 'Shuffle' : 'Chronological' })}
|
|
/>
|
|
<ToggleRow
|
|
label="Always playing"
|
|
hint="Keep a continuous playout running instead of starting on demand."
|
|
checked={alwaysOn}
|
|
overridesTemplate={alwaysOverrides}
|
|
onChange={(next) => onPatch({ playoutMode: next ? 'Continuous' : 'OnDemand' })}
|
|
/>
|
|
</DetailSection>
|
|
|
|
<DetailSection label="Channel template" hint={`Leave on Inherit to use the batch template (${batchTemplateName}).`}>
|
|
<Select
|
|
size="sm"
|
|
value={override.templateId ?? ''}
|
|
onChange={(event) => onPatch({ templateId: event.target.value })}
|
|
options={[{ value: '', label: `Inherit (${batchTemplateName})` }, ...templates.map((candidate) => ({ value: String(candidate.id), label: candidate.name }))]}
|
|
/>
|
|
</DetailSection>
|
|
|
|
<DetailSection label="Advanced">
|
|
<button
|
|
type="button"
|
|
className="ctv-press"
|
|
onClick={() => setAdvancedOpen((open) => !open)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
width: '100%',
|
|
padding: '9px 11px',
|
|
borderRadius: 'var(--radius-md)',
|
|
border: '1px solid var(--border-control)',
|
|
background: 'var(--surface-card)',
|
|
cursor: 'pointer',
|
|
textAlign: 'left'
|
|
}}
|
|
>
|
|
<Settings2 aria-hidden="true" size={15} style={{ color: 'var(--text-secondary)' }} />
|
|
<span style={{ flex: 1, font: 'var(--weight-medium) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
|
|
{advancedOpen ? 'Hide advanced settings' : 'Override advanced settings'}
|
|
</span>
|
|
<span style={{ font: 'var(--text-2xs)/1 var(--font-mono)', color: 'var(--text-disabled)' }}>
|
|
{overrideCount > 0 ? `${overrideCount} overridden · ` : ''}
|
|
{template.name}
|
|
</span>
|
|
</button>
|
|
{advancedOpen && (
|
|
<AdvancedFields
|
|
template={template}
|
|
overrides={override.advanced}
|
|
setOverrides={onPatchAdvanced}
|
|
ffmpegProfiles={ffmpegProfiles}
|
|
fillerPresets={fillerPresets}
|
|
watermarks={watermarks}
|
|
/>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection label="Query & size">
|
|
<ReadRow label="Order" value={shuffleOn ? 'Shuffled' : 'In order'} />
|
|
<ReadRow label="Estimated items" value={<span style={monoStyle}>{proposal.itemCount}</span>} />
|
|
<ReadRow label="Streaming mode" value={streamingLabel} />
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
label="Content sources"
|
|
hint={
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
<Info aria-hidden="true" size={12} /> Per-source rotation weights & query corrections are coming (#425).
|
|
</span>
|
|
}
|
|
>
|
|
{membersState.status === 'loading' && (
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="status">
|
|
<Spinner size={14} />
|
|
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading content sources…</span>
|
|
</div>
|
|
)}
|
|
{membersState.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.3 var(--font-sans)', color: 'var(--status-error)' }}>{membersState.message}</span>
|
|
</div>
|
|
)}
|
|
{membersState.status === 'success' && (
|
|
<>
|
|
<div style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
|
{membersState.totalCount} {membersState.totalCount === 1 ? 'source' : 'sources'}
|
|
{membersState.totalCount > membersState.members.length ? ` · showing first ${membersState.members.length}` : ''}
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
border: '1px solid var(--border-hairline)',
|
|
borderRadius: 'var(--radius-md)',
|
|
overflow: 'hidden',
|
|
maxHeight: 220,
|
|
overflowY: 'auto'
|
|
}}
|
|
>
|
|
{membersState.members.length === 0 ? (
|
|
<div style={{ padding: '12px 14px', font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
|
|
No members resolved.
|
|
</div>
|
|
) : (
|
|
membersState.members.map((member, i) => (
|
|
<div
|
|
key={member.id}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
padding: '7px 12px',
|
|
borderTop: i ? '1px solid var(--border-hairline)' : 'none'
|
|
}}
|
|
>
|
|
{member.artwork ? (
|
|
<img
|
|
src={member.artwork}
|
|
alt=""
|
|
loading="lazy"
|
|
style={{ width: 28, height: 42, objectFit: 'cover', borderRadius: 3, flex: '0 0 auto' }}
|
|
/>
|
|
) : (
|
|
<div style={{ width: 28, height: 42, borderRadius: 3, background: 'var(--ctv-surface-2)', flex: '0 0 auto' }} />
|
|
)}
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
font: 'var(--text-xs)/1.3 var(--font-sans)',
|
|
color: 'var(--text-primary)',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis'
|
|
}}
|
|
>
|
|
{member.title}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</DetailSection>
|
|
</SlideOver>
|
|
);
|
|
}
|
|
|
|
// The per-channel Advanced override grid. Reuses the shared INHERIT/omit adapters
|
|
// (builder/advancedOptions) so the "leave on Inherit = omit from request" contract (#135) is
|
|
// identical to the Channel Builder. Playback order + playout mode are NOT here — they are the
|
|
// Playback pane's Shuffle / Always-playing toggles; group/categories are Channel-Builder-only.
|
|
function AdvancedFields({
|
|
template,
|
|
overrides,
|
|
setOverrides,
|
|
ffmpegProfiles,
|
|
fillerPresets,
|
|
watermarks
|
|
}: {
|
|
template: ChannelTemplate;
|
|
overrides: Overrides;
|
|
setOverrides: (updater: (prev: Overrides) => Overrides) => void;
|
|
ffmpegProfiles: FFmpegProfile[];
|
|
fillerPresets: FillerPreset[];
|
|
watermarks: Watermark[];
|
|
}) {
|
|
const { setOverride, idSelectOptions, selValue, onSelect, textValue, onText, textPlaceholder, boolValue } =
|
|
useAdvancedOverrides(template, overrides, setOverrides);
|
|
const t = template as unknown as Record<string, unknown>;
|
|
const str = (value: unknown): string | null => (value == null ? null : String(value));
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 2 }}>
|
|
<AdvGroup label="Streaming">
|
|
<Select
|
|
size="sm"
|
|
label="FFmpeg profile"
|
|
value={selValue('ffmpegProfileId')}
|
|
options={[
|
|
inheritOption(ffmpegProfiles.find((p) => p.id === t.ffmpegProfileId)?.name ?? `#${t.ffmpegProfileId}`),
|
|
...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }))
|
|
]}
|
|
onChange={onSelect('ffmpegProfileId', Number)}
|
|
/>
|
|
<Select
|
|
size="sm"
|
|
label="Streaming mode"
|
|
value={selValue('streamingMode')}
|
|
options={[
|
|
inheritOption(STREAMING_MODE_LABELS[t.streamingMode as StreamingMode] ?? str(t.streamingMode)),
|
|
...STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))
|
|
]}
|
|
onChange={onSelect('streamingMode', (raw) => raw)}
|
|
/>
|
|
<Select
|
|
size="sm"
|
|
label="Transcode mode"
|
|
value={selValue('transcodeMode')}
|
|
options={[inheritOption(str(t.transcodeMode)), ...TRANSCODE_MODES.map((value) => ({ value, label: value }))]}
|
|
onChange={onSelect('transcodeMode', (raw) => raw)}
|
|
/>
|
|
</AdvGroup>
|
|
|
|
<AdvGroup label="Filler">
|
|
<Select size="sm" label="Fallback filler" value={selValue('fallbackFillerId')} options={idSelectOptions('fallbackFillerId', fillerPresets)} onChange={onSelect('fallbackFillerId', Number)} />
|
|
<Select size="sm" label="Pre-roll filler" value={selValue('preRollFillerId')} options={idSelectOptions('preRollFillerId', fillerPresets)} onChange={onSelect('preRollFillerId', Number)} />
|
|
<Select size="sm" label="Mid-roll filler" value={selValue('midRollFillerId')} options={idSelectOptions('midRollFillerId', fillerPresets)} onChange={onSelect('midRollFillerId', Number)} />
|
|
<Select size="sm" label="Post-roll filler" value={selValue('postRollFillerId')} options={idSelectOptions('postRollFillerId', fillerPresets)} onChange={onSelect('postRollFillerId', Number)} />
|
|
<Select size="sm" label="Watermark" value={selValue('watermarkId')} options={idSelectOptions('watermarkId', watermarks)} onChange={onSelect('watermarkId', Number)} />
|
|
</AdvGroup>
|
|
|
|
<AdvGroup label="Playback">
|
|
<Input size="sm" label="Preferred audio language" value={textValue('preferredAudioLanguageCode')} onChange={onText('preferredAudioLanguageCode')} placeholder={textPlaceholder('preferredAudioLanguageCode', 'e.g. eng')} />
|
|
<Input size="sm" label="Preferred subtitle language" value={textValue('preferredSubtitleLanguageCode')} onChange={onText('preferredSubtitleLanguageCode')} placeholder={textPlaceholder('preferredSubtitleLanguageCode', 'e.g. eng')} />
|
|
<Input size="sm" label="Preferred audio title" value={textValue('preferredAudioTitle')} onChange={onText('preferredAudioTitle')} placeholder={textPlaceholder('preferredAudioTitle')} />
|
|
<Select size="sm" label="Subtitle mode" value={selValue('subtitleMode')} options={[inheritOption(str(t.subtitleMode)), ...SUBTITLE_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('subtitleMode', (raw) => raw)} />
|
|
<Select size="sm" label="Stream selector mode" value={selValue('streamSelectorMode')} options={[inheritOption(str(t.streamSelectorMode)), ...STREAM_SELECTOR_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('streamSelectorMode', (raw) => raw)} />
|
|
<Input size="sm" label="Stream selector" value={textValue('streamSelector')} onChange={onText('streamSelector')} placeholder={textPlaceholder('streamSelector')} />
|
|
</AdvGroup>
|
|
|
|
<AdvGroup label="Behavior">
|
|
<Select size="sm" label="Music video credits" value={selValue('musicVideoCreditsMode')} options={[inheritOption(str(t.musicVideoCreditsMode)), ...MUSIC_VIDEO_CREDITS_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('musicVideoCreditsMode', (raw) => raw)} />
|
|
<Select size="sm" label="Song video mode" value={selValue('songVideoMode')} options={[inheritOption(str(t.songVideoMode)), ...SONG_VIDEO_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('songVideoMode', (raw) => raw)} />
|
|
<Select size="sm" label="Idle behavior" value={selValue('idleBehavior')} options={[inheritOption(str(t.idleBehavior)), ...IDLE_BEHAVIORS.map((value) => ({ value, label: value }))]} onChange={onSelect('idleBehavior', (raw) => raw)} />
|
|
<Select size="sm" label="Fixed start time" value={selValue('fixedStartTimeBehavior')} options={[inheritOption(str(t.fixedStartTimeBehavior)), ...FIXED_START_TIME_BEHAVIORS.map((value) => ({ value, label: value }))]} onChange={onSelect('fixedStartTimeBehavior', (raw) => raw)} />
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Random start point</span>
|
|
<Switch checked={boolValue('randomStartPoint')} onChange={(next) => setOverride('randomStartPoint', next)} />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Shuffle schedule items</span>
|
|
<Switch checked={boolValue('shuffleScheduleItems')} onChange={(next) => setOverride('shuffleScheduleItems', next)} />
|
|
</div>
|
|
</AdvGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AdvGroup({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<div style={{ ...eyebrowStyle, color: 'var(--text-secondary)' }}>{label}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const centeredMessageStyle: CSSProperties = {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '100%',
|
|
gap: 12,
|
|
padding: '40px 24px'
|
|
};
|