ux: move timing into its own panel
This commit is contained in:
@@ -77,8 +77,10 @@ edited from the dashboard and persists in NodeCG's database. Set
|
||||
- **Ads** — CRUD for ads (strip text, column text, column image from the
|
||||
Assets dialog, QR URL, enabled), with a live QR preview per row.
|
||||
- **Choreography / OBS** — connection status, mode/phase badges, countdown to
|
||||
the next automatic transition, timing configuration, break-slide content,
|
||||
and manual controls: show ad now, skip ad, break toggle.
|
||||
the next automatic transition, break-slide content, and manual controls:
|
||||
show ad now, skip ad, break toggle.
|
||||
- **Timing** — the choreography timing settings: ad interval, ad duration,
|
||||
break gap, break ad duration, ads per burst.
|
||||
- **OBS setup check** — read-only verification of the live OBS against the
|
||||
expected scenes/sources/transforms/transitions, with concrete fixes.
|
||||
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
"file": "choreo-panel.html",
|
||||
"headerColor": "#8B2635"
|
||||
},
|
||||
{
|
||||
"name": "timing-panel",
|
||||
"title": "Timing",
|
||||
"width": 3,
|
||||
"file": "timing-panel.html",
|
||||
"headerColor": "#8B2635"
|
||||
},
|
||||
{
|
||||
"name": "obs-check-panel",
|
||||
"title": "OBS setup check",
|
||||
|
||||
@@ -4,12 +4,11 @@ import type NodeCG from "@nodecg/types";
|
||||
import type {
|
||||
AssetFile,
|
||||
BreakSlide,
|
||||
ChoreoConfig,
|
||||
ObsStatus,
|
||||
SchedulerState,
|
||||
} from "../types";
|
||||
import { t } from "./i18n";
|
||||
import { parseNum, useReplicantForm, useReplicantValue } from "./lib";
|
||||
import { useReplicantForm, useReplicantValue } from "./lib";
|
||||
|
||||
const obsStatusReplicant = nodecg.Replicant(
|
||||
"obsStatus",
|
||||
@@ -19,10 +18,6 @@ const schedulerStateReplicant = nodecg.Replicant(
|
||||
"schedulerState",
|
||||
) as unknown as NodeCG.ServerReplicant<SchedulerState>;
|
||||
|
||||
const choreoConfigReplicant = nodecg.Replicant(
|
||||
"choreoConfig",
|
||||
) as unknown as NodeCG.ServerReplicant<ChoreoConfig>;
|
||||
|
||||
const breakSlideReplicant = nodecg.Replicant(
|
||||
"breakSlide",
|
||||
) as unknown as NodeCG.ServerReplicant<BreakSlide>;
|
||||
@@ -42,36 +37,9 @@ const formatCountdown = (ms: number) => {
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
function NumField({
|
||||
label,
|
||||
value,
|
||||
onValid,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onValid: (n: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="text-xs text-gray-600">
|
||||
{label}
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min="1"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const n = parseNum(e.target.value);
|
||||
if (n !== null && n > 0) onValid(n);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChoreoPanel() {
|
||||
const status = useReplicantValue<ObsStatus>(obsStatusReplicant);
|
||||
const sched = useReplicantValue<SchedulerState>(schedulerStateReplicant);
|
||||
const [cfg, updateCfg] = useReplicantForm<ChoreoConfig>(choreoConfigReplicant);
|
||||
const [slide, updateSlide] = useReplicantForm<BreakSlide>(breakSlideReplicant);
|
||||
const logoFiles = useReplicantValue<AssetFile[]>(logoAssetsReplicant);
|
||||
|
||||
@@ -243,41 +211,6 @@ export function ChoreoPanel() {
|
||||
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className={sectionTitleCls}>{t.choreo.timingSection}</h2>
|
||||
{cfg ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumField
|
||||
label={t.choreo.adIntervalLabel}
|
||||
value={cfg.normalAdIntervalMinutes}
|
||||
onValid={(n) => updateCfg({ normalAdIntervalMinutes: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.adDurationLabel}
|
||||
value={cfg.adDurationSeconds}
|
||||
onValid={(n) => updateCfg({ adDurationSeconds: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.breakGapLabel}
|
||||
value={cfg.breakGapSeconds}
|
||||
onValid={(n) => updateCfg({ breakGapSeconds: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.breakAdDurationLabel}
|
||||
value={cfg.breakAdDurationSeconds}
|
||||
onValid={(n) => updateCfg({ breakAdDurationSeconds: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.breakAdsPerBurstLabel}
|
||||
value={cfg.breakAdsPerBurst}
|
||||
onValid={(n) => updateCfg({ breakAdsPerBurst: n })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">{t.common.loading}</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
|
||||
import type NodeCG from "@nodecg/types";
|
||||
import type { ChoreoConfig } from "../types";
|
||||
import { t } from "./i18n";
|
||||
import { parseNum, useReplicantForm } from "./lib";
|
||||
|
||||
const choreoConfigReplicant = nodecg.Replicant(
|
||||
"choreoConfig",
|
||||
) as unknown as NodeCG.ServerReplicant<ChoreoConfig>;
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none";
|
||||
|
||||
function NumField({
|
||||
label,
|
||||
value,
|
||||
onValid,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onValid: (n: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="text-xs text-gray-600">
|
||||
{label}
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min="1"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const n = parseNum(e.target.value);
|
||||
if (n !== null && n > 0) onValid(n);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimingPanel() {
|
||||
const [cfg, updateCfg] = useReplicantForm<ChoreoConfig>(choreoConfigReplicant);
|
||||
|
||||
if (!cfg) {
|
||||
return <div className="p-3 text-sm text-gray-500">{t.common.loading}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
||||
{t.choreo.timingSection}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NumField
|
||||
label={t.choreo.adIntervalLabel}
|
||||
value={cfg.normalAdIntervalMinutes}
|
||||
onValid={(n) => updateCfg({ normalAdIntervalMinutes: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.adDurationLabel}
|
||||
value={cfg.adDurationSeconds}
|
||||
onValid={(n) => updateCfg({ adDurationSeconds: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.breakGapLabel}
|
||||
value={cfg.breakGapSeconds}
|
||||
onValid={(n) => updateCfg({ breakGapSeconds: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.breakAdDurationLabel}
|
||||
value={cfg.breakAdDurationSeconds}
|
||||
onValid={(n) => updateCfg({ breakAdDurationSeconds: n })}
|
||||
/>
|
||||
<NumField
|
||||
label={t.choreo.breakAdsPerBurstLabel}
|
||||
value={cfg.breakAdsPerBurst}
|
||||
onValid={(n) => updateCfg({ breakAdsPerBurst: n })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { TimingPanel } from "./TimingPanel";
|
||||
import { locale } from "./i18n";
|
||||
|
||||
document.documentElement.lang = locale;
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<TimingPanel />);
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="./panel.css" type="text/css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./timing-panel-bootstrap.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user