docs(404): mirror the weight UI into the MultiCollections design prototype #466
@@ -18,12 +18,20 @@
|
||||
];
|
||||
|
||||
const EDIT_ITEMS = [
|
||||
{ kind: "manual", name: "Friends", scheduleAsGroup: true },
|
||||
{ kind: "manual", name: "Seinfeld", scheduleAsGroup: true },
|
||||
{ kind: "manual", name: "The Office (US)", scheduleAsGroup: false },
|
||||
{ kind: "smart", name: "Sitcoms — Unwatched, Short", scheduleAsGroup: false },
|
||||
{ kind: "manual", name: "Friends", scheduleAsGroup: true, weight: 3 },
|
||||
{ kind: "manual", name: "Seinfeld", scheduleAsGroup: true, weight: 2 },
|
||||
{ kind: "manual", name: "The Office (US)", scheduleAsGroup: false, weight: 1 },
|
||||
{ kind: "smart", name: "Sitcoms — Unwatched, Short", scheduleAsGroup: false, weight: 1 },
|
||||
];
|
||||
|
||||
// Per-source weight bounds mirror the API's MultiCollectionItemWeight validator (1..1000, #404).
|
||||
const WEIGHT_MIN = 1;
|
||||
const WEIGHT_MAX = 1000;
|
||||
const clampWeight = (raw) => {
|
||||
const n = Math.trunc(Number(raw));
|
||||
return Number.isFinite(n) ? Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, n)) : WEIGHT_MIN;
|
||||
};
|
||||
|
||||
const flushRow = (first) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -73,7 +81,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
function EditItemRow({ item, first }) {
|
||||
function EditItemRow({ item, first, weight, pct, onWeight }) {
|
||||
const [checked, setChecked] = React.useState(item.scheduleAsGroup);
|
||||
return (
|
||||
<div style={flushRow(first)}>
|
||||
@@ -92,6 +100,31 @@
|
||||
{item.name}
|
||||
</span>
|
||||
<Badge tone="neutral">{item.kind === "smart" ? "Smart" : "Manual"}</Badge>
|
||||
{/* Per-source weight (#404): relative share of airtime under the Weighted Shuffle order.
|
||||
Bounded 1..1000 (API validator); the % is a display of the integer share (3:1 → 75/25). */}
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }} title="Relative share of airtime under the Weighted Shuffle playback order">
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Weight</span>
|
||||
<input
|
||||
type="number"
|
||||
min={WEIGHT_MIN}
|
||||
max={WEIGHT_MAX}
|
||||
value={weight}
|
||||
aria-label={`Weight for ${item.name}`}
|
||||
onChange={(e) => onWeight(e.target.value)}
|
||||
onBlur={(e) => onWeight(String(clampWeight(e.target.value)))}
|
||||
style={{
|
||||
width: 62,
|
||||
height: 30,
|
||||
padding: "0 8px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
color: "var(--text-primary)",
|
||||
font: "var(--text-sm)/1 var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
<span style={{ ...mono, minWidth: 34, textAlign: "right", font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-faint)" }}>{pct}%</span>
|
||||
</div>
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 7, cursor: "pointer" }} title="Schedule as group">
|
||||
<Switch checked={checked} onChange={setChecked} size="sm" />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap" }}>Schedule as group</span>
|
||||
@@ -109,6 +142,15 @@
|
||||
const [smartSel, setSmartSel] = React.useState("");
|
||||
const items = existing ? EDIT_ITEMS : [];
|
||||
|
||||
// Weights are held per-item (as strings, so the number field edits smoothly); the % share is a
|
||||
// display of the integer weights, and "Reset to fair share" sets every weight back to 1 (#404).
|
||||
const [weights, setWeights] = React.useState(items.map((it) => String(it.weight ?? WEIGHT_MIN)));
|
||||
const totalWeight = weights.reduce((s, w) => s + clampWeight(w), 0);
|
||||
const pctFor = (i) => (totalWeight > 0 ? Math.round((clampWeight(weights[i]) / totalWeight) * 100) : 0);
|
||||
const setWeightAt = (i, v) => setWeights((cur) => cur.map((w, j) => (j === i ? v : w)));
|
||||
const alreadyFairShare = items.length > 0 && weights.every((w) => clampWeight(w) === WEIGHT_MIN);
|
||||
const resetToFairShare = () => setWeights(items.map(() => String(WEIGHT_MIN)));
|
||||
|
||||
const collectionOptions = [
|
||||
{ label: "Select a collection…", value: "" },
|
||||
{ label: "Action Movies", value: "1" },
|
||||
@@ -162,7 +204,28 @@
|
||||
No collections added yet.
|
||||
</div>
|
||||
) : (
|
||||
items.map((item, i) => <EditItemRow key={item.kind + ":" + item.name} item={item} first={i === 0} />)
|
||||
<React.Fragment>
|
||||
{items.map((item, i) => (
|
||||
<EditItemRow
|
||||
key={item.kind + ":" + item.name}
|
||||
item={item}
|
||||
first={i === 0}
|
||||
weight={weights[i]}
|
||||
pct={pctFor(i)}
|
||||
onWeight={(v) => setWeightAt(i, v)}
|
||||
/>
|
||||
))}
|
||||
{/* fair-share footer (#404): fair-share is not a separate mode — it is Weighted Shuffle
|
||||
with all weights left at 1, so this resets rather than writing a different order. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px var(--pad-cell-x)", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ flex: 1, minWidth: 0, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Weights set each source’s share of airtime under the <strong>Weighted Shuffle</strong> playback order (set on the schedule item). Equal weights = fair share.
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" disabled={alreadyFairShare} onClick={resetToFairShare}>
|
||||
Reset to fair share
|
||||
</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user