T-HUB/apps/web/src/components/control-room/multiview-grid.tsx
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

40 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { Button } from "../ui/primitives";
import { PreviewTap } from "./preview-tap";
import { tileTally } from "./tally";
import type { Production, Source } from "./types";
export type GridTier = "2x1" | "2x2" | "3x3" | "4x4" | "custom";
export function liveTapIds(sources: Source[], tier: GridTier, focused: string | null, upgrades: string[], mobile: boolean): string[] {
const ids = sources.map(source => source.id);
const valid = (id: string): boolean => id === "program" || ids.includes(id);
const candidates = mobile ? [focused ?? "program"] : tier === "2x1" || tier === "2x2" ? ["program", ...(focused ? [focused] : []), ...ids] : ["program", ...(focused ? [focused] : []), ...upgrades];
return [...new Set(candidates)].filter(valid).slice(0, mobile ? 1 : Math.min(4, tier === "2x1" ? 2 : 4));
}
export function MultiviewGrid({ production, observed, channelId, fixture, mobile, liveIds, focused, onFocus, tier, onTier, upgrades, onUpgrade }: { production: Production; observed: Production | null; channelId: string; fixture: boolean; mobile: boolean; liveIds: string[]; focused: string | null; onFocus: (id: string) => void; tier: GridTier; onTier: (tier: GridTier) => void; upgrades: string[]; onUpgrade: (id: string) => void }): React.ReactElement {
const [columns, setColumns] = useState(3);
const capacity = tier === "2x1" ? 2 : tier === "2x2" ? 4 : tier === "3x3" ? 9 : 16;
const tiles = [{ id: "program", label: "PGM · encoder output", kind: "hls" as const }, ...production.sources].slice(0, capacity);
const selectedSourceIds = production.scenes.find(scene => scene.id === production.buses.preview)?.slots.map(slot => slot.sourceId) ?? [];
return <section aria-label="Multiview" className="min-w-0 rounded-lg border border-border-subtle bg-surface p-3">
<div className="mb-3 flex flex-wrap items-end gap-3">
<label className="text-xs">Multiview layout<select className="cr-field mt-1" value={tier} onChange={event => onTier(event.target.value as GridTier)}>{["2x1", "2x2", "3x3", "4x4", "custom"].map(value => <option key={value}>{value}</option>)}</select></label>
{tier === "custom" && <label className="text-xs">Columns<input className="cr-field mt-1" type="number" min={1} max={4} value={columns} onChange={event => setColumns(Math.max(1, Math.min(4, Number(event.target.value) || 1)))} /></label>}
<span className="pb-3 text-xs text-text-faint">{liveIds.length}/{mobile ? 1 : 4} live slots · tap to focus</span>
</div>
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${mobile ? (tier === "2x1" ? 1 : 2) : tier === "custom" ? columns : tier === "3x3" ? 3 : tier === "4x4" ? 4 : 2}, minmax(0, 1fr))` }}>
{tiles.map(source => {
const tally = source.id === "program" ? observed?.buses.program ? "program" : "none" : tileTally(observed, source.id);
return <div key={source.id} data-source={source.id} data-tally={tally} className={`min-w-0 rounded-md border-2 p-1 ${tally === "program" ? "border-red-500" : tally === "preview" ? "border-green-500" : "border-border"}`}>
<button className="min-h-11 w-full text-left" aria-label={`Focus ${source.label}`} aria-pressed={focused === source.id} onClick={() => onFocus(source.id)}>
<div className="mb-1 flex min-h-7 flex-wrap items-center justify-between gap-1 px-1 text-xs"><span className="break-words">{source.label}</span><span className="font-mono">{tally === "program" ? "PGM" : tally === "preview" ? "PST" : "—"}</span></div>
<PreviewTap channelId={channelId} tap={source.id} fixture={fixture} live={liveIds.includes(source.id)} selected={selectedSourceIds.includes(source.id)} label={source.label} />
</button>
<p className="px-1 py-1 text-[10px] text-text-faint">{fixture ? "SAMPLE" : "Health not reported"}</p>
{!mobile && (tier === "3x3" || tier === "4x4" || tier === "custom") && source.id !== "program" && source.id !== focused && <Button className="min-h-11 w-full justify-center" disabled={!upgrades.includes(source.id) && liveIds.length >= 4} onClick={() => onUpgrade(source.id)}>{upgrades.includes(source.id) ? "Remove live" : "Upgrade live"}</Button>}
</div>;
})}
</div>
<p className="mt-3 text-xs text-text-faint">Extra live tiles can strain the device. Red/green tally uses the worker’s realized version. Unknown versions have no tally.</p>
</section>;
}