restyle the rest of the pages
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-08-02 16:32:00 +01:00
parent 11809a7da8
commit eab3f8be20
46 changed files with 2022 additions and 857 deletions

View File

@@ -1,94 +1,72 @@
import { useEffect, useState } from "react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, X } from "lucide-react";
import clsx from "clsx";
import Card from "../components/Card.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
import { getUser } from "../utils/api/users.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import IsLivePulse from "../components/IsLivePulse.tsx";
import NoAccessPage from "../components/NoAccessPage.tsx";
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { saveSection, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts";
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
import type { Marker, StreamStatus } from "../utils/types.ts";
import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
/** Ten-minute gridlines behind the marker ticks. */
const GRID_INTERVAL_SECONDS = 600;
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const useCopy = () => {
const [copied, setCopied] = useState<string | null>(null);
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
<div className="flex items-center gap-2.5">
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
</div>
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
</div>
);
const copy = (key: string, text: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
};
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-green-600 flex-shrink-0" />
<p className="text-gray-700">{job.label} saved</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
</div>
</div>
);
return { copied, copy };
};
const StreamControl = () => {
const [user, setUser] = useState<User | null>(null);
const { user, loading } = useAuth();
const { copied, copy } = useCopy();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now());
const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null);
const markerInputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<number[]>([]);
const [inValue, setInValue] = useState("");
const [outValue, setOutValue] = useState("");
const [rangeName, setRangeName] = useState("");
const [rangeError, setRangeError] = useState<string | null>(null);
const [rangeBusy, setRangeBusy] = useState(false);
const [clipDuration, setClipDuration] = useState(30);
const [clipTitle, setClipTitle] = useState("");
const [clipDescription, setClipDescription] = useState("");
const [clipName, setClipName] = useState("");
const [clipError, setClipError] = useState<string | null>(null);
const [startMarkerId, setStartMarkerId] = useState<string>("");
const [endMarkerId, setEndMarkerId] = useState<string>("");
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
const [keyVisible, setKeyVisible] = useState(false);
const [ingestUrl, setIngestUrl] = useState<string | null>(null);
const [jobs, setJobs] = useState<TrackedJob[]>([]);
const isStreaming = streamStatus?.isStreaming ?? false;
const startMs = streamStatus?.startDate ? stringToDate(streamStatus.startDate).getTime() : null;
const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000);
const elapsed = formatTimecode(elapsedSeconds);
useEffect(() => {
getUser().then(setUser).catch(() => setUser(null));
}, []);
// ── Polling ─────────────────────────────────────────────────────────────
useEffect(() => {
if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -98,24 +76,18 @@ const StreamControl = () => {
}, [user]);
useEffect(() => {
if (!user || !streamStatus?.id) {
setStreamDetails(null);
return;
}
getStreamHistory(user.id)
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
.catch(() => {});
}, [user, streamStatus?.id]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [streamDetails?.startDate]);
}, []);
useEffect(() => {
if (!user) return;
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
}, [user]);
const refreshMarkers = () => {
getMarkers()
.then((data) => setMarkers(data
.then((data) => setMarkers([...data]
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.catch(() => {});
};
@@ -123,6 +95,7 @@ const StreamControl = () => {
useEffect(() => {
if (!isStreaming) {
setMarkers([]);
setSelected([]);
return;
}
refreshMarkers();
@@ -130,285 +103,499 @@ const StreamControl = () => {
return () => clearInterval(interval);
}, [isStreaming, streamStatus?.id]);
// One interval for the whole pending set, rebuilt only when that set changes.
const pendingKey = jobs.filter((job) => !isSettled(job)).map((job) => job.uuid).join(",");
useEffect(() => {
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
if (pending.length === 0) return;
if (!pendingKey) return;
const uuids = pendingKey.split(",");
const interval = setInterval(() => {
pending.forEach((job) => {
getJob(job.uuid)
.then((result) => {
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
: j));
})
uuids.forEach((uuid) => {
getJob(uuid)
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
: job)))
.catch(() => {});
});
}, 1000);
return () => clearInterval(interval);
}, [jobs]);
}, [pendingKey]);
const addJob = (uuid: string, label: string) => {
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
// ── Marker helpers ──────────────────────────────────────────────────────
const offsetOf = (marker: Marker) =>
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
// Ticking two markers fills the range. Done here rather than in an effect so a
// marker poll can't stomp on timecodes the user has since typed over.
const toggleMarker = (id: number) => {
const next = selected.includes(id)
? selected.filter((current) => current !== id)
// Third tick replaces the older of the two.
: selected.length < 2 ? [...selected, id] : [selected[1], id];
setSelected(next);
setRangeError(null);
if (next.length !== 2 || startMs === null) return;
const offsets = next
.map((markerId) => markers.find((marker) => marker.id === markerId))
.filter((marker): marker is Marker => marker !== undefined)
.map(offsetOf)
.sort((a, b) => a - b);
if (offsets.length !== 2) return;
setInValue(formatTimecode(offsets[0]));
setOutValue(formatTimecode(offsets[1]));
};
// 'M' anywhere on the page jumps to the marker field.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== "m" && event.key !== "M") return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
event.preventDefault();
markerInputRef.current?.focus();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
// ── Actions ─────────────────────────────────────────────────────────────
const addJob = (uuid: string, label: string) =>
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
const handleAddMarker = async () => {
if (!markerMessage.trim() || markerBusy) return;
if (markerBusy) return;
setMarkerBusy(true);
setMarkerError(null);
try {
await createMarker(markerMessage.trim());
// Dropping first and naming later isn't possible — markers have no PATCH —
// so an unnamed marker keeps the timecode it landed on.
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
setMarkerMessage("");
refreshMarkers();
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
} finally {
setMarkerBusy(false);
}
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
addJob(uuid, `Clip (last ${clipDuration}s)`);
setClipTitle("");
setClipDescription("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to clip section");
}
};
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Tap two markers above to pick a start and end");
return;
}
try {
const uuid = await saveSectionByMarkers(
Number(startMarkerId),
Number(endMarkerId),
markerSaveTitle || undefined,
markerSaveDescription || undefined
);
addJob(uuid, "Save (by markers)");
setStartMarkerId("");
setEndMarkerId("");
setMarkerSaveTitle("");
setMarkerSaveDescription("");
} catch (err) {
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
}
};
const handleDeleteMarker = async (id: number) => {
try {
await deleteMarker(id);
if (startMarkerId === String(id)) setStartMarkerId("");
if (endMarkerId === String(id)) setEndMarkerId("");
setSelected((prev) => prev.filter((current) => current !== id));
refreshMarkers();
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
}
};
const handleChipClick = (id: number) => {
const idStr = String(id);
setMarkerSaveError(null);
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
if (!startMarkerId) { setStartMarkerId(idStr); return; }
if (!endMarkerId) { setEndMarkerId(idStr); return; }
setEndMarkerId(idStr);
};
if (!user) {
return (
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to control your stream.
</div>
);
}
const elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
const inSeconds = parseTimecode(inValue);
const outSeconds = parseTimecode(outValue);
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
? formatTime(outSeconds - inSeconds)
: null;
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
const handleSaveRange = async () => {
setRangeError(null);
if (startMs === null) {
setRangeError("No stream is running to take a range from.");
return;
}
if (inSeconds === null || outSeconds === null) {
setRangeError("Timecodes read as hh:mm:ss.");
return;
}
if (outSeconds <= inSeconds) {
setRangeError("The out point has to come after the in point.");
return;
}
if (outSeconds > elapsedSeconds) {
setRangeError("That out point hasn't been streamed yet.");
return;
}
setRangeBusy(true);
try {
const uuid = await saveSection(
new Date(startMs + inSeconds * 1000).toISOString(),
new Date(startMs + outSeconds * 1000).toISOString(),
rangeName || undefined
);
addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`);
clearSelection();
} catch (err) {
setRangeError(err instanceof Error ? err.message : "Failed to save the range");
} finally {
setRangeBusy(false);
}
};
const clearSelection = () => {
setSelected([]);
setInValue("");
setOutValue("");
setRangeName("");
setRangeError(null);
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipName || undefined);
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
setClipName("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
}
};
// ── Timeline ────────────────────────────────────────────────────────────
const gridlines = useMemo(() => {
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
return Array.from(
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
);
}, [elapsedSeconds]);
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
if (loading) return null;
if (!user) return <NoAccessPage />;
return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Ingest" title="Stream panel" />
{isStreaming &&
<Card className={"bg-card pb-10"}>
<div className={"p-6"}>
<p className={"pb-3 font-heading text-text-strong text-xl"}>Server credentials</p>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
{/* ── Main column ───────────────────────────────────────── */}
<div className="flex flex-col gap-[26px]">
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Server credentials</h2>
{isStreaming ? (
<div className="flex items-center gap-2">
<IsLivePulse />
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
</div>
) : (
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
)}
</div>
<p className={"font-body text-muted text-sm p-1"}>Ingest URL</p>
<input
type={"text"}
contentEditable={"false"}
className={"bg-fields rounded-xl focus:outline-none w-full p-3 text-text-strong text-sm font-data"}
readOnly={true}
defaultValue={"rtmp://vodsystem.ddns.net:1935/live"}
/>
</div>
</Card>
}
{/* Hero status bar */}
<Card className={clsx(
"p-6 flex items-center justify-between gap-6 border-l-4",
isStreaming ? "border-l-red-500" : "border-l-gray-200"
)}>
<div className="flex items-center gap-3.5">
<span className="relative flex h-3.5 w-3.5">
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
</span>
<div>
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
{isStreaming && streamDetails?.startDate ? (
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
) : !isStreaming ? (
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
) : null}
</div>
</div>
{isStreaming && elapsed ? (
<div className="text-right">
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
</div>
) : (
<Radio size={28} className="text-gray-200" />
)}
</Card>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Card className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-accent flex-shrink-0" />
<p className={labelClass}>Ingest URL</p>
<div className="flex items-center gap-2.5 pt-2">
<input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
className={inputClass}
readOnly
value={ingestUrl ?? ""}
placeholder="asking the server…"
className={clsx(monoInputClass, "flex-1")}
/>
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker
</BlueButton>
<button
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
disabled={!ingestUrl}
onClick={() => ingestUrl && copy("url", ingestUrl)}
>
{copied === "url" ? "Copied" : "Copy"}
</button>
</div>
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
{markers.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
{markers.map((m) => {
const isStart = startMarkerId === String(m.id);
const isEnd = endMarkerId === String(m.id);
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
<div className="flex items-center gap-2.5 pt-2">
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
</div>
<button className={chipButtonClass} onClick={() => setKeyVisible((visible) => !visible)}>
{keyVisible ? "Hide" : "Reveal"}
</button>
<button className={chipButtonClass} onClick={() => copy("key", user.streamKey)}>
{copied === "key" ? "Copied" : "Copy"}
</button>
</div>
<p className="pt-3 font-body text-[13px] text-muted">
Keep this private — anyone holding the key can stream to your account.
</p>
</Card>
{isStreaming ? (
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Live markers</h2>
<p className="font-data text-[13px] text-faint-mono tabular-nums">t = {elapsed}</p>
</div>
<div className="flex items-center gap-3">
<input
ref={markerInputRef}
type="text"
value={markerMessage}
onChange={(event) => setMarkerMessage(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleAddMarker()}
placeholder="What just happened? (optional)"
className={clsx(inputClass, "flex-1")}
/>
<Button
variant="dark"
onClick={handleAddMarker}
disabled={markerBusy}
className="whitespace-nowrap px-5 py-3"
>
+ Drop marker at {elapsed}
</Button>
</div>
<p className="pt-2.5 font-body text-[13px] text-muted">
or press{" "}
<kbd className="bg-chip rounded-[5px] px-1.5 py-0.5 font-data text-[11px] text-text-control">M</kbd>
{" "}to jump here
</p>
{markerError && <p className="pt-2 font-body text-[13px] text-live-red">{markerError}</p>}
{/* Timeline overview */}
<div className="relative mt-5 h-[74px] rounded-chip bg-fields overflow-hidden">
{gridlines.map((left, index) => (
<span
key={index}
className="absolute inset-y-0 w-px bg-inactive/60"
style={{ left: `${left}%` }}
/>
))}
{markers.map((marker) => {
const left = elapsedSeconds > 0
? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100))
: 0;
return (
<div key={m.id} className="group relative flex-shrink-0">
<span
key={marker.id}
title={marker.message}
className={clsx(
"absolute inset-y-0 w-[2px]",
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
)}
style={{ left: `${left}%` }}
/>
);
})}
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
</div>
{/* Marker list */}
<div className="flex flex-col gap-2 pt-5">
{markers.length === 0 && (
<p className="font-body text-[13px] text-muted py-2">
No markers yet. Drop one the moment something happens — you can cut around it later.
</p>
)}
{markers.map((marker) => {
const isSelected = selected.includes(marker.id);
const offset = offsetOf(marker);
return (
<div key={marker.id} className="group flex items-center gap-1">
<button
onClick={() => handleChipClick(m.id)}
onClick={() => toggleMarker(marker.id)}
className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
isSelected
? "bg-armed inset-shadow-armed"
: "bg-card inset-shadow-hairline hover:bg-fields"
)}
>
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
<span className="text-[11px] text-gray-400">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-primary font-medium"> · start</span>}
{isEnd && <span className="text-accent font-medium"> · end</span>}
<span className={clsx(
"flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
isSelected ? "bg-accent text-on-accent" : "bg-inactive"
)}>
{isSelected && <Check size={12} strokeWidth={3} />}
</span>
<span className="font-data text-[13px] text-accent tabular-nums flex-shrink-0">
{formatTimecode(offset)}
</span>
<span className="flex-1 font-body text-sm text-text-strong truncate">
{marker.message}
</span>
<span className="font-data text-[12px] text-faint-mono flex-shrink-0">
{toRelativeOffset(elapsedSeconds - offset)}
</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
title="Delete marker"
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
onClick={() => handleDeleteMarker(marker.id)}
title="Remove marker"
className="flex h-7 w-7 items-center justify-center rounded-chip text-faint-mono opacity-0 group-hover:opacity-100 hover:bg-chip hover:text-live-red transition-all duration-150"
>
<X size={10} />
<X size={14} />
</button>
</div>
);
})}
</div>
)}
</Card>
{/* Forefront actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<Card className="p-6 flex flex-col gap-3">
<CardHeader icon={Scissors} title="Clip the last few seconds" />
<div className="flex flex-col gap-1.5">
<label className={labelClass}>Duration (seconds)</label>
<input
type="number"
min={1}
value={clipDuration}
onChange={(e) => setClipDuration(Number(e.target.value))}
className={inputClass}
/>
</div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
</Card>
) : (
<Card className="p-8 flex flex-col gap-2">
<p className={kickerClass}>Off air</p>
<h2 className="font-heading text-[27px] leading-[1.25]">Nothing is coming in right now.</h2>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty">
Point OBS at the ingest URL above with your stream key, and this panel fills
in — elapsed time, markers, and the range cutter.
</p>
</Card>
)}
</div>
<Card className="p-6 flex flex-col gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
{markers.length < 2 ? (
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
</span>
<span className="text-gray-300">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
</span>
{/* ── Side panel ────────────────────────────────────────── */}
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
{isStreaming && (
<>
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="font-heading text-[22px]">Make something from a range</h2>
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
{selected.length === 2 && rangeLength
? `Two markers selected — ${inValue} to ${outValue}.`
: "Tick two markers, or type a timecode by hand."}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-in">In</label>
<input
id="range-in"
value={inValue}
onChange={(event) => setInValue(event.target.value)}
placeholder="00:00:00"
className={clsx(monoInputClass, "bg-card")}
/>
</div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
</>
)}
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
</Card>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-out">Out</label>
<input
id="range-out"
value={outValue}
onChange={(event) => setOutValue(event.target.value)}
placeholder="00:00:00"
className={clsx(monoInputClass, "bg-card")}
/>
</div>
</div>
<Card className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
) : (
<div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
<div className="flex items-baseline justify-between gap-3">
<span className="font-body text-[13.5px] text-text-body">Range length</span>
<span className="font-data text-[15px] text-text-primary tabular-nums">
{rangeLength ?? "—"}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-name">Name</label>
<input
id="range-name"
value={rangeName}
onChange={(event) => setRangeName(event.target.value)}
placeholder="Give it a name"
className={clsx(inputClass, "bg-card")}
/>
</div>
{rangeError && <p className="font-body text-[13px] text-live-red">{rangeError}</p>}
<div className="flex flex-col gap-2.5">
<Button
onClick={handleSaveRange}
disabled={rangeBusy || rangeLength === null}
className="w-full py-3"
>
{rangeBusy ? "Queueing…" : "Save range as VOD"}
</Button>
<Button variant="ghost" onClick={clearSelection} className="w-full">
Clear selection
</Button>
</div>
</div>
)}
</Card>
</>
)}
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
Takes the last stretch of stream, counted back from this moment.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
<input
id="clip-duration"
type="number"
min={1}
max={180}
value={clipDuration}
onChange={(event) => setClipDuration(Number(event.target.value))}
className={clsx(monoInputClass, "bg-card")}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-name">Name</label>
<input
id="clip-name"
value={clipName}
onChange={(event) => setClipName(event.target.value)}
placeholder="Optional"
className={clsx(inputClass, "bg-card")}
/>
</div>
</div>
{clipError && <p className="font-body text-[13px] text-live-red">{clipError}</p>}
<Button
variant="olive"
onClick={handleClip}
disabled={clipDuration < 1 || clipDuration > 180}
className="w-full py-3"
>
Cut the last {clipDuration}s as a clip
</Button>
</div>
</>
)}
{jobs.length > 0 && (
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Background jobs</p>
<p className="font-data text-[11px] text-faint-mono">{runningJobs} running</p>
</div>
{jobs.map((job) => <JobProgress key={job.uuid} job={job} />)}
</div>
)}
<div className="bg-panel rounded-panel p-6 flex flex-col gap-3">
<p className={kickerClass}>How it works</p>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body text-pretty">
Tick two markers to fill the range automatically, or type a timecode by hand.
Saving queues a background job — you can keep streaming while it runs.
</p>
</div>
</aside>
</div>
</div>
);
};