docs(404): mirror the weight UI into the MultiCollections design prototype #466

Merged
timothy merged 1 commits from docs/404-designsync-mirror into main 2026-07-19 13:25:27 +02:00
@@ -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 sources 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>