Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions app/assets/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,26 @@ section.usersAdmin {
color: var(--color-text-inverse);
}

/*
* Informational banner (softer than banner-warning). Tol vibrant cyan, used for staleness and
* other non-error advisories. An "(i) " prefix is included in the text to carry meaning without
* relying on colour.
*/
.banner-info {
background-color: rgba(51, 187, 238, 0.15); /* Tol vibrant cyan at 15% */
color: inherit;
border-left: 4px solid #33BBEE;
font-weight: normal;
text-align: left;
}

@media (prefers-color-scheme: dark) {
.banner-info {
background-color: rgba(85, 204, 238, 0.2); /* lighter tint for dark backgrounds */
border-left-color: #55CCEE;
}
}

.banner-queue {
background-color: #1a1a1a;
color: #fff;
Expand Down Expand Up @@ -2927,6 +2947,79 @@ a.kiosk-brand {
color: var(--color-text-secondary);
}

/*
* Source badges for webcast URLs. Text label is the primary channel; colour + shape are
* redundant channels so CB users can still distinguish the two states. Solid cyan pill for TBA,
* outlined grey pill for manual override. Dark-mode overrides preserve contrast on the page.
*/
.badge-tba,
.badge-manual {
display: inline-block;
font-size: 0.7rem;
font-weight: bold;
padding: 0.15rem 0.45rem;
border-radius: 0.75rem;
margin-left: 0.4rem;
vertical-align: middle;
white-space: nowrap;
letter-spacing: 0.02em;
}

.badge-tba {
background-color: #33BBEE; /* Tol vibrant cyan */
color: #000;
border: 1px solid #33BBEE;
}

.badge-manual {
background-color: transparent;
color: var(--color-text-secondary);
border: 1px solid #BBBBBB; /* Tol vibrant grey */
}

@media (prefers-color-scheme: dark) {
.badge-tba {
background-color: #55CCEE;
color: #000;
border-color: #55CCEE;
}
.badge-manual {
border-color: #888888;
}
}

.admin-stream-staleness {
margin-bottom: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}

/* TBA event-key override input (Unit 7) */
.admin-stream-tba-key {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.25rem 0 0.5rem 0;
font-size: 0.85rem;
}

.admin-stream-tba-key-label {
font-weight: bold;
color: var(--color-text-secondary);
}

.admin-stream-tba-key-input {
font-family: var(--font-monospace, monospace);
width: 10rem;
}

.admin-stream-tba-key-hint {
flex-basis: 100%;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}

/*
*
* KIOSK PLAYOFF MODE
Expand Down
29 changes: 29 additions & 0 deletions app/common/storage/rb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,35 @@ export async function removeTournamentWebcast(
}
}

/**
* Set or clear the TBA event key for a tournament. Returns {ok:true} when the server accepted
* the change, or {ok:false, reason} when the server rejected it (e.g. invalid format, not
* authorized). Requires ROLE_SUPERUSER or ROLE_ADMIN on the server side.
*/
export async function setTournamentTbaEventKey(
tournamentId: string,
tbaEventKey: string | null,
): Promise<{ ok: true } | { ok: false; reason: string }> {
try {
const resp = await rbfetch(`/api/tournament/${tournamentId}/tba-event-key`, {
method: "PUT",
body: JSON.stringify({ tbaEventKey }),
});
if (resp.ok) return { ok: true };
const reason =
resp.status === 400
? "invalid TBA event key format"
: resp.status === 403
? "not authorized"
: resp.status === 404
? "tournament not found"
: `HTTP ${resp.status}`;
return { ok: false, reason };
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message : "network error" };
}
}

// ---------------------------------------------------------------------------
// Match Videos
// ---------------------------------------------------------------------------
Expand Down
156 changes: 140 additions & 16 deletions app/routes/admin/tournament-streams-page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import RequireLogin from "~/common/auth/RequireLogin.tsx";
import { useTournamentList } from "~/common/storage/dbhooks.ts";
import {
addTournamentWebcast,
removeTournamentWebcast,
setTournamentTbaEventKey,
} from "~/common/storage/rb.ts";
import type { RBTournament } from "~/types/RBTournament.ts";
import Spinner from "~/common/Spinner.tsx";
Expand All @@ -19,6 +20,11 @@ function safeHref(url: string): string {
return "";
}

/**
* Best-effort parse of the merged webcasts field. After the TBA data foundation (P0), the server
* returns a pre-canonicalized string[]. The legacy JSON-array-string shape is kept as a fallback
* for the first render after upgrade when IndexedDB may still hold older entries.
*/
function parseWebcasts(tournament: RBTournament): string[] {
const raw = tournament.webcasts;
if (Array.isArray(raw)) return raw;
Expand All @@ -33,12 +39,48 @@ function parseWebcasts(tournament: RBTournament): string[] {
return [];
}

/** Humanize "time since" for the staleness banner without pulling in a date library. */
function relativeAgo(iso: string): string {
const then = Date.parse(iso);
if (isNaN(then)) return iso;
const minutes = Math.round((Date.now() - then) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} hr ago`;
const days = Math.round(hours / 24);
return `${days} day${days === 1 ? "" : "s"} ago`;
}

/** Client-side regex matching the server's TBA event-key validation exactly. */
const TBA_EVENT_KEY_RE = /^20\d{2}[a-z][a-z0-9]{1,15}$/;

function TournamentRow({ tournament }: { tournament: RBTournament }) {
const [url, setUrl] = useState("");
const [saving, setSaving] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [streams, setStreams] = useState<string[]>(parseWebcasts(tournament));
const [tbaKeyDraft, setTbaKeyDraft] = useState<string>(tournament.tbaEventKey ?? "");
const [tbaSaving, setTbaSaving] = useState(false);
const [tbaMsg, setTbaMsg] = useState<string | null>(null);

const tbaKeyValid =
tbaKeyDraft.trim() === "" || TBA_EVENT_KEY_RE.test(tbaKeyDraft.trim().toLowerCase());

const tbaSet = useMemo(
() => new Set(tournament.webcastsFromTba ?? []),
[tournament.webcastsFromTba],
);
const isTbaSourced = (u: string) => tbaSet.has(u);

const stalenessMessage = useMemo(() => {
if (!tournament.webcastsStale) return null;
if (tournament.webcastsLastSync) {
return `(i) Webcast data last synced ${relativeAgo(tournament.webcastsLastSync)} — may be out of date.`;
}
return "(i) Webcast data has not yet synced — the TBA event key may be incorrect or no key is configured.";
}, [tournament.webcastsStale, tournament.webcastsLastSync]);

const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -58,6 +100,40 @@ function TournamentRow({ tournament }: { tournament: RBTournament }) {
}
};

const handleSaveTbaKey = async () => {
if (tbaSaving) return;
const draft = tbaKeyDraft.trim().toLowerCase();
if (draft !== "" && !TBA_EVENT_KEY_RE.test(draft)) {
setTbaMsg("Invalid key format. e.g. 2026onto");
return;
}
setTbaSaving(true);
setTbaMsg(null);
const result = await setTournamentTbaEventKey(tournament.id, draft === "" ? null : draft);
setTbaSaving(false);
if (result.ok) {
setTbaKeyDraft(draft); // canonicalize the input box
setTbaMsg("Saved");
setTimeout(() => setTbaMsg(null), 1500);
} else {
setTbaMsg(`Failed: ${result.reason}`);
}
};

const handleClearTbaKey = async () => {
setTbaKeyDraft("");
setTbaSaving(true);
setTbaMsg(null);
const result = await setTournamentTbaEventKey(tournament.id, null);
setTbaSaving(false);
if (result.ok) {
setTbaMsg("Cleared");
setTimeout(() => setTbaMsg(null), 1500);
} else {
setTbaMsg(`Failed: ${result.reason}`);
}
};

const handleRemove = async (streamUrl: string) => {
setRemoving(streamUrl);
const ok = await removeTournamentWebcast(tournament.id, streamUrl);
Expand All @@ -73,23 +149,71 @@ function TournamentRow({ tournament }: { tournament: RBTournament }) {
return (
<div className="admin-stream-tournament">
<h3>{tournament.name}</h3>
<div className="admin-stream-tba-key">
<label className="admin-stream-tba-key-label" htmlFor={`tba-key-${tournament.id}`}>
TBA event key
</label>
<input
id={`tba-key-${tournament.id}`}
type="text"
className="admin-stream-tba-key-input"
value={tbaKeyDraft}
onChange={(e) => setTbaKeyDraft(e.target.value)}
placeholder="e.g. 2026onto"
spellCheck={false}
disabled={tbaSaving}
/>
<button
type="button"
onClick={handleSaveTbaKey}
disabled={tbaSaving || !tbaKeyValid}
title={tbaKeyValid ? "Save TBA event key" : "Invalid key format"}
>
{tbaSaving ? "..." : "Save"}
</button>
<button
type="button"
onClick={handleClearTbaKey}
disabled={tbaSaving || tbaKeyDraft === ""}
title="Clear the TBA event key"
>
Clear
</button>
{tbaMsg && <span className="admin-stream-msg">{tbaMsg}</span>}
<span className="admin-stream-tba-key-hint">
Matches the TBA event URL. Auto-populated on FRC sync; override only when wrong.
</span>
</div>
{stalenessMessage && (
<div className="banner banner-info admin-stream-staleness">{stalenessMessage}</div>
)}
{streams.length > 0 ? (
<ul className="admin-stream-list">
{streams.map((s, i) => (
<li key={i} className="admin-stream-item">
<a href={safeHref(s)} target="_blank" rel="noopener noreferrer">
{s}
</a>
<button
className="admin-stream-remove"
onClick={() => handleRemove(s)}
disabled={removing === s}
title="Remove stream"
>
{removing === s ? "..." : "\u00D7"}
</button>
</li>
))}
{streams.map((s, i) => {
const fromTba = isTbaSourced(s);
return (
<li key={i} className="admin-stream-item">
<a href={safeHref(s)} target="_blank" rel="noopener noreferrer">
{s}
</a>
<span className={fromTba ? "badge-tba" : "badge-manual"}>
{fromTba ? "From TBA" : "Manual override"}
</span>
<button
className="admin-stream-remove"
onClick={() => handleRemove(s)}
disabled={fromTba || removing === s}
title={
fromTba
? "Served by TBA — remove by clearing the TBA event key or contacting TBA."
: "Remove stream"
}
>
{removing === s ? "..." : "\u00D7"}
</button>
</li>
);
})}
</ul>
) : (
<p className="admin-stream-empty">No streams configured</p>
Expand Down
14 changes: 14 additions & 0 deletions app/types/RBTournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,19 @@ export type RBTournament = {
startTime: Date;
endTime: Date;
weekNumber: number;
// Merged list of manual + TBA webcast URLs. Since the backend added the TBA data foundation,
// this is a typed string[] — the legacy JSON-array-string / single-string forms are kept in
// the type union for defensive parsing of older IndexedDB entries during the first post-sync.
webcasts?: string[] | string | null;
// Subset of `webcasts` that came from TBA. Used by the admin UI to badge each URL.
// Absent when the tournament has no tba_event_key set.
webcastsFromTba?: string[];
// Timestamp (ISO-8601 string as serialized by Java Instant) of the most recent successful TBA
// sync. Null when no successful sync has happened yet.
webcastsLastSync?: string | null;
// True when TBA data is stale or missing: last sync older than threshold, last sync failed,
// or tba_event_key is set but no RB_TBA_EVENT row exists yet.
webcastsStale?: boolean;
// TBA event key (e.g. "2026onto") — admin-editable (see Unit 7) when auto-derivation was wrong.
tbaEventKey?: string | null;
};
Loading
Loading