templates/chicorytv-admin/Settings.jsx wired into app.html; design_handoff_settings/ carries the implementation spec + API mapping. Synced to the Claude Design project via DesignSync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
417 lines
24 KiB
React
417 lines
24 KiB
React
// Settings screen — GROUND-UP: left sub-nav rail + focused form panes, one pane
|
||
// per settings group (mirrors /api/settings/*). Dense "system settings" rows:
|
||
// label + help text left, control right. A floating save bar appears when dirty.
|
||
(function () {
|
||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||
const { Card, Button, IconButton, Badge, Input, Select, Switch, Tooltip, StatusDot, Tag } = NS;
|
||
const Ico = window.Ico;
|
||
const D = window.CTV_DATA;
|
||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||
|
||
const SECTIONS = [
|
||
{ id: "general", label: "General", icon: "SlidersHorizontal", hint: "Theme & language" },
|
||
{ id: "streaming",label: "Streaming", icon: "Clapperboard", hint: "FFmpeg & transcoding" },
|
||
{ id: "playout", label: "Playout", icon: "ListVideo", hint: "Build defaults" },
|
||
{ id: "xmltv", label: "Guide (XMLTV)",icon: "CalendarDays", hint: "EPG output" },
|
||
{ id: "scanner", label: "Scanner", icon: "Radar", hint: "Library refresh" },
|
||
{ id: "logging", label: "Logging", icon: "ScrollText", hint: "Log levels" },
|
||
{ id: "system", label: "System", icon: "Server", hint: "HDHR, sources, about" },
|
||
];
|
||
|
||
const PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "480p H.264"];
|
||
const LOG_LEVELS = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"];
|
||
const DEFAULTS = {
|
||
theme: "Dark", language: "English (en-US)",
|
||
ffmpegPath: "/usr/bin/ffmpeg", ffprobePath: "/usr/bin/ffprobe",
|
||
defaultProfile: "1080p H.264", preferredAudioLanguage: "English (eng)",
|
||
useEmbeddedSubtitles: true, extractEmbeddedSubtitles: false,
|
||
probeInterlaced: true, saveReports: false,
|
||
globalWatermark: "(none)", globalFallbackFiller: "Retro Bumpers",
|
||
hlsIdleTimeout: "60", hlsWorkAhead: "1", hlsInitialSegments: "1",
|
||
hlsDirectFormat: "MPEG-TS", mpegTsScript: "(default template)",
|
||
playoutDays: "2", skipMissing: true, scriptedTimeout: "30",
|
||
xmltvDays: "2", xmltvTimeZone: "Local", xmltvBlockBehavior: "Split time evenly",
|
||
refreshHours: "6",
|
||
logDefault: "Information", logScanning: "Information", logScheduling: "Information",
|
||
logSearching: "Information", logStreaming: "Information", logHttp: "Warning",
|
||
tunerCount: "2",
|
||
};
|
||
|
||
/* ---------- shared row primitives ---------- */
|
||
|
||
function Pane({ title, subtitle, children }) {
|
||
return (
|
||
<div style={{ maxWidth: 760, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||
<div>
|
||
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
|
||
{subtitle && <div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// One setting: label + help left, control right. Flush rows inside Card padded={false}.
|
||
function Row({ label, help, children, first, control = 260 }) {
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
|
||
</div>
|
||
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LegacyCallout({ children }) {
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)" }}>
|
||
<Ico n="ExternalLink" s={14} color="var(--text-disabled)" />
|
||
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{children}</span>
|
||
<Badge tone="neutral">Legacy UI</Badge>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PathInput({ value, onChange }) {
|
||
const ok = value.startsWith("/");
|
||
return (
|
||
<Input size="sm" fullWidth value={value} onChange={(e) => onChange(e.target.value)}
|
||
style={mono}
|
||
trailing={ok
|
||
? <Ico n="CircleCheck" s={14} color="var(--status-ok)" />
|
||
: <Tooltip label="File not found"><Ico n="CircleAlert" s={14} color="var(--status-error)" /></Tooltip>} />
|
||
);
|
||
}
|
||
|
||
function NumInput({ value, onChange, unit, w = 120 }) {
|
||
return (
|
||
<Input size="sm" type="number" value={value} onChange={(e) => onChange(e.target.value)}
|
||
style={{ width: w, ...mono }}
|
||
trailing={unit && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>} />
|
||
);
|
||
}
|
||
|
||
/* ---------- panes ---------- */
|
||
|
||
function GeneralPane({ v, set }) {
|
||
return (
|
||
<Pane title="General" subtitle="Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.">
|
||
<Card padded={false}>
|
||
<Row first label="Theme" help="Legacy UI color scheme.">
|
||
<Select size="sm" fullWidth value={v.theme} onChange={(e) => set("theme", e.target.value)} options={["Dark", "Light"]} />
|
||
</Row>
|
||
<Row label="Language" help="Display language for the legacy UI.">
|
||
<Select size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)}
|
||
options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} />
|
||
</Row>
|
||
</Card>
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
function StreamingPane({ v, set }) {
|
||
const [resW, setResW] = React.useState("");
|
||
const [resH, setResH] = React.useState("");
|
||
const [customRes, setCustomRes] = React.useState([{ id: 1, w: 1920, h: 820 }, { id: 2, w: 2560, h: 1080 }]);
|
||
return (
|
||
<Pane title="Streaming" subtitle="FFmpeg engine, transcoding defaults and HLS session tuning.">
|
||
<Card title="FFmpeg" padded={false}>
|
||
<Row first label="FFmpeg path" help="Binary used for all transcoding." control={320}>
|
||
<PathInput value={v.ffmpegPath} onChange={(x) => set("ffmpegPath", x)} />
|
||
</Row>
|
||
<Row label="FFprobe path" help="Binary used to inspect media files." control={320}>
|
||
<PathInput value={v.ffprobePath} onChange={(x) => set("ffprobePath", x)} />
|
||
</Row>
|
||
<Row label="Default profile" help="Transcoding profile for new channels.">
|
||
<Select size="sm" fullWidth value={v.defaultProfile} onChange={(e) => set("defaultProfile", e.target.value)} options={PROFILES} />
|
||
</Row>
|
||
<Row label="Preferred audio language" help="Track picked when media has multiple audio languages.">
|
||
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)}
|
||
options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} />
|
||
</Row>
|
||
<Row label="Use embedded subtitles" help="Burn in / pass through subtitles found in media files.">
|
||
<Switch size="sm" checked={v.useEmbeddedSubtitles} onChange={(x) => set("useEmbeddedSubtitles", x)} />
|
||
</Row>
|
||
<Row label="Extract embedded subtitles" help="Copy text subtitles out ahead of playback (slower scans).">
|
||
<Switch size="sm" checked={v.extractEmbeddedSubtitles} onChange={(x) => set("extractEmbeddedSubtitles", x)} />
|
||
</Row>
|
||
<Row label="Probe for interlaced frames" help="Deeper scan to auto-apply deinterlacing.">
|
||
<Switch size="sm" checked={v.probeInterlaced} onChange={(x) => set("probeInterlaced", x)} />
|
||
</Row>
|
||
<Row label="Save troubleshooting reports" help="Write a report per transcode session to the config folder.">
|
||
<Switch size="sm" checked={v.saveReports} onChange={(x) => set("saveReports", x)} />
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card title="Global defaults" padded={false}>
|
||
<Row first label="Watermark" help="Applied to every channel without its own watermark.">
|
||
<Select size="sm" fullWidth value={v.globalWatermark} onChange={(e) => set("globalWatermark", e.target.value)}
|
||
options={["(none)", "Station bug — corner", "Station bug — large"]} />
|
||
</Row>
|
||
<Row label="Fallback filler" help="Plays when a playout has nothing scheduled.">
|
||
<Select size="sm" fullWidth value={v.globalFallbackFiller} onChange={(e) => set("globalFallbackFiller", e.target.value)}
|
||
options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} />
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card title="HLS sessions" padded={false}>
|
||
<Row first label="Idle timeout" help="Stop a segmenter after no client has asked for segments.">
|
||
<NumInput value={v.hlsIdleTimeout} onChange={(x) => set("hlsIdleTimeout", x)} unit="sec" />
|
||
</Row>
|
||
<Row label="Work-ahead limit" help="Max segmenters transcoding ahead of live at once.">
|
||
<NumInput value={v.hlsWorkAhead} onChange={(x) => set("hlsWorkAhead", x)} unit="sessions" />
|
||
</Row>
|
||
<Row label="Initial segments" help="Segments ready before playback starts.">
|
||
<NumInput value={v.hlsInitialSegments} onChange={(x) => set("hlsInitialSegments", x)} unit="segments" />
|
||
</Row>
|
||
<Row label="HLS Direct output format" help="Container for HLS Direct channels.">
|
||
<Select size="sm" fullWidth value={v.hlsDirectFormat} onChange={(e) => set("hlsDirectFormat", e.target.value)} options={["MPEG-TS", "MP4", "MKV"]} />
|
||
</Row>
|
||
<Row label="Default MPEG-TS script" help="Channels in MPEG-TS mode use this script unless overridden.">
|
||
<Select size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} />
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card title="Custom resolutions" subtitle="Extra target resolutions selectable in FFmpeg profiles" padded={false}>
|
||
{customRes.map((r, i) => (
|
||
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||
<Ico n="Proportions" s={15} color="var(--text-disabled)" />
|
||
<span style={{ flex: 1, ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{r.w} × {r.h}</span>
|
||
<Tag>custom</Tag>
|
||
<IconButton size="sm" variant="ghost" title="Delete resolution" onClick={() => setCustomRes(customRes.filter((x) => x.id !== r.id))}>
|
||
<Ico n="Trash2" s={14} />
|
||
</IconButton>
|
||
</div>
|
||
))}
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: customRes.length ? "1px solid var(--border-hairline)" : "none", background: "var(--ctv-bg-sunken)" }}>
|
||
<Input size="sm" type="number" placeholder="width" value={resW} onChange={(e) => setResW(e.target.value)} style={{ width: 96, ...mono }} />
|
||
<span style={{ color: "var(--text-disabled)" }}>×</span>
|
||
<Input size="sm" type="number" placeholder="height" value={resH} onChange={(e) => setResH(e.target.value)} style={{ width: 96, ...mono }} />
|
||
<span style={{ flex: 1 }} />
|
||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={13} />} disabled={!resW || !resH}
|
||
onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
|
||
Add resolution
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card title="FFmpeg profiles" subtitle="Fine-grained transcoding presets" padded={false}
|
||
actions={<Badge tone="neutral">{PROFILES.length} profiles</Badge>}>
|
||
{PROFILES.map((p, i) => (
|
||
<div key={p} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||
<Ico n="Cpu" s={15} color="var(--ctv-accent)" />
|
||
<span style={{ flex: 1, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{p}</span>
|
||
{p === v.defaultProfile && <Badge tone="accent">Default</Badge>}
|
||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{i + 1} ch</span>
|
||
</div>
|
||
))}
|
||
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
|
||
</div>
|
||
</Card>
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
function PlayoutPane({ v, set }) {
|
||
return (
|
||
<Pane title="Playout" subtitle="Defaults for how far ahead playouts are built and how gaps are handled.">
|
||
<Card padded={false}>
|
||
<Row first label="Days to build" help="How many days of programming each playout keeps ahead.">
|
||
<NumInput value={v.playoutDays} onChange={(x) => set("playoutDays", x)} unit="days" />
|
||
</Row>
|
||
<Row label="Skip missing items" help="Skip items whose files are missing instead of failing the build.">
|
||
<Switch size="sm" checked={v.skipMissing} onChange={(x) => set("skipMissing", x)} />
|
||
</Row>
|
||
<Row label="Scripted schedule timeout" help="Max run time for external scripted-schedule processes.">
|
||
<NumInput value={v.scriptedTimeout} onChange={(x) => set("scriptedTimeout", x)} unit="sec" />
|
||
</Row>
|
||
</Card>
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
function XmltvPane({ v, set }) {
|
||
return (
|
||
<Pane title="Guide (XMLTV)" subtitle="Shape of the XMLTV guide data served to Jellyfin and other clients.">
|
||
<Card padded={false}>
|
||
<Row first label="Days to build" help="Guide horizon exported per channel.">
|
||
<NumInput value={v.xmltvDays} onChange={(x) => set("xmltvDays", x)} unit="days" />
|
||
</Row>
|
||
<Row label="Time zone" help="Timestamps written into the XMLTV file.">
|
||
<Select size="sm" fullWidth value={v.xmltvTimeZone} onChange={(e) => set("xmltvTimeZone", e.target.value)} options={["Local", "UTC"]} />
|
||
</Row>
|
||
<Row label="Block behavior" help="How block-schedule playouts are represented in the guide.">
|
||
<Select size="sm" fullWidth value={v.xmltvBlockBehavior} onChange={(e) => set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} />
|
||
</Row>
|
||
</Card>
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
function ScannerPane({ v, set }) {
|
||
return (
|
||
<Pane title="Scanner" subtitle="Background scanning of local libraries.">
|
||
<Card padded={false}>
|
||
<Row first label="Library refresh interval" help="Hours between automatic local-library scans. 0 disables automatic scanning.">
|
||
<NumInput value={v.refreshHours} onChange={(x) => set("refreshHours", x)} unit="hours" />
|
||
</Row>
|
||
</Card>
|
||
{v.refreshHours === "0" && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--status-warn-soft)", border: "1px solid transparent" }}>
|
||
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
|
||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||
Automatic scanning is disabled — libraries only update when scanned manually.
|
||
</span>
|
||
</div>
|
||
)}
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
function LoggingPane({ v, set }) {
|
||
const CATS = [
|
||
{ key: "logDefault", label: "Default", help: "Everything without a more specific category." },
|
||
{ key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." },
|
||
{ key: "logScheduling", label: "Scheduling", help: "Playout builds and schedule evaluation." },
|
||
{ key: "logSearching", label: "Searching", help: "Search index and queries." },
|
||
{ key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." },
|
||
{ key: "logHttp", label: "HTTP", help: "ASP.NET request logging." },
|
||
];
|
||
return (
|
||
<Pane title="Logging" subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.">
|
||
<Card padded={false}>
|
||
{CATS.map((c, i) => (
|
||
<Row key={c.key} first={i === 0} label={c.label} help={c.help} control={180}>
|
||
<Select size="sm" fullWidth value={v[c.key]} onChange={(e) => set(c.key, e.target.value)} options={LOG_LEVELS} />
|
||
</Row>
|
||
))}
|
||
</Card>
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
function SystemPane({ v, set }) {
|
||
const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
|
||
return (
|
||
<Pane title="System" subtitle="HDHomeRun emulation, connected media sources and server info.">
|
||
<Card title="HDHomeRun" padded={false}>
|
||
<Row first label="Tuner count" help="Concurrent streams advertised to clients that speak HDHR.">
|
||
<NumInput value={v.tunerCount} onChange={(x) => set("tunerCount", x)} unit="tuners" />
|
||
</Row>
|
||
<Row label="Device UUID" help="Identity presented to HDHR clients." control={340}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", justifyContent: "flex-end" }}>
|
||
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis" }}>{UUID}</span>
|
||
<IconButton size="sm" variant="ghost" title="Copy UUID"><Ico n="Copy" s={13} /></IconButton>
|
||
</div>
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card title="Media sources" subtitle="Read-only — add or edit connections in the legacy UI" padded={false}>
|
||
{D.sources.map((s, i) => (
|
||
<div key={s.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||
<Ico n={s.kind === "Local" ? "HardDrive" : "Cast"} s={15} color="var(--text-secondary)" />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</span>
|
||
<span style={{ marginLeft: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.detail}</span>
|
||
</div>
|
||
<StatusDot status={s.status === "connected" ? "ok" : "warn"} size={7} />
|
||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", width: 62, textAlign: "right" }}>{s.lastScan}</span>
|
||
</div>
|
||
))}
|
||
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||
<LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card title="About" padded={false}>
|
||
<Row first label="Version" control={220}>
|
||
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.4.0</span>
|
||
</Row>
|
||
<Row label="Health checks" help="14 checks — 2 warnings." control={220}>
|
||
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Dashboard</Button>
|
||
</Row>
|
||
</Card>
|
||
</Pane>
|
||
);
|
||
}
|
||
|
||
/* ---------- screen ---------- */
|
||
|
||
function Settings() {
|
||
const [section, setSection] = React.useState("general");
|
||
const [saved, setSaved] = React.useState(DEFAULTS);
|
||
const [draft, setDraft] = React.useState(DEFAULTS);
|
||
const [justSaved, setJustSaved] = React.useState(false);
|
||
const set = (k, val) => setDraft((d) => ({ ...d, [k]: val }));
|
||
const dirty = Object.keys(DEFAULTS).filter((k) => draft[k] !== saved[k]);
|
||
|
||
const PANES = {
|
||
general: GeneralPane, streaming: StreamingPane, playout: PlayoutPane,
|
||
xmltv: XmltvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
|
||
};
|
||
const PaneCmp = PANES[section];
|
||
|
||
return (
|
||
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
|
||
{/* sub-nav rail */}
|
||
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
|
||
{SECTIONS.map((s) => {
|
||
const active = s.id === section;
|
||
return (
|
||
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
|
||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
|
||
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
|
||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
|
||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
|
||
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ font: `var(--weight-medium) var(--text-sm)/1 var(--font-sans)`, color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
|
||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* pane */}
|
||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
|
||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
|
||
<PaneCmp key={section} v={draft} set={set} />
|
||
</div>
|
||
|
||
{/* floating save bar */}
|
||
{(dirty.length > 0 || justSaved) && (
|
||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
|
||
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||
{justSaved ? (
|
||
<>
|
||
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
|
||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Settings saved</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||
{dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
|
||
</span>
|
||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
|
||
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}
|
||
onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
|
||
Save changes
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
window.CTVSettings = Settings;
|
||
})();
|