feat(frontend): make markers list vertical in stream page #46

Merged
dylan merged 3 commits from feat/vertical-marker-list-frontend into master 2026-09-16 20:11:12 +01:00
4 changed files with 254 additions and 129 deletions

View File

@@ -0,0 +1,18 @@
import type { ElementType } from "react";
type Props = {
icon: ElementType;
title: string;
accent?: "primary" | "accent";
};
const CardHeader = ({ icon: Icon, title, accent = "primary" }: Props) => (
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${accent === "primary" ? "bg-terracotta/10" : "bg-olive/10"}`}>
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
</div>
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
</div>
);
export default CardHeader;

View File

@@ -0,0 +1,173 @@
import { useState } from "react";
import { Bookmark, Plus, X } from "lucide-react";
import clsx from "clsx";
import Box from "../Box.tsx";
import CardHeader from "../CardHeader.tsx";
import PrimaryButton from "../buttons/PrimaryButton.tsx";
import type { Marker } from "../../utils/types.ts";
import { formatLocalTime } from "../../utils/utils.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
type RailSegment = "none" | "plain" | "lit";
const railClass = (segment: RailSegment) => clsx(
"w-px",
segment === "lit" && "bg-terracotta",
segment === "plain" && "bg-inactive-strong/60",
segment === "none" && "bg-transparent"
);
type MarkerRowProps = {
marker: Marker;
role: "start" | "end" | "between" | null;
/** Rail segments above and below the dot — "lit" inside the chosen section. */
railAbove: RailSegment;
railBelow: RailSegment;
onSelect: () => void;
onDelete: () => void;
};
const MarkerRow = ({ marker, role, railAbove, railBelow, onSelect, onDelete }: MarkerRowProps) => (
<li className="group relative flex gap-3">
{/* Timeline rail: a dot per marker, with the segment between the chosen
start and end lit so the selected section reads as one run. */}
<div className="flex w-2.5 flex-col items-center flex-shrink-0">
<span className={clsx("h-3.5 flex-shrink-0", railClass(railAbove))} />
<span className={clsx(
"h-2.5 w-2.5 rounded-full border-2 flex-shrink-0 transition-colors duration-150",
role === "start" && "border-terracotta bg-terracotta",
role === "end" && "border-olive bg-olive",
role === "between" && "border-terracotta bg-card",
role === null && "border-inactive-strong bg-card"
)} />
<span className={clsx("flex-1", railClass(railBelow))} />
</div>
{/* The rail spans the full row, so the breathing room goes on the button —
the line stays continuous while neighbouring rings no longer touch. */}
<button
onClick={onSelect}
className={clsx(
"min-w-0 flex-1 rounded-md my-0.5 py-2 pl-2.5 pr-7 text-left transition-colors duration-150",
role === "start" && "bg-selected ring-1 ring-terracotta",
role === "end" && "bg-selected ring-1 ring-olive",
(role === "between" || role === null) && "hover:bg-hover"
)}
>
<p className="truncate text-sm font-medium text-text-strong">{marker.message}</p>
<p className="font-data text-[11px] text-muted tabular-nums">
{formatLocalTime(marker.timestamp)}
{role === "start" && <span className="font-medium text-terracotta"> · start</span>}
{role === "end" && <span className="font-medium text-olive"> · end</span>}
</p>
</button>
<button
onClick={onDelete}
title="Delete marker"
aria-label={`Delete marker ${marker.message}`}
className="absolute right-1 top-2.5 flex h-5 w-5 items-center justify-center rounded-md text-muted opacity-0 transition duration-150 hover:bg-hover hover:text-error focus-visible:opacity-100 group-hover:opacity-100"
>
<X size={12} />
</button>
</li>
);
type Props = {
/** Newest first — the list renders them in the order given. */
markers: Marker[];
startMarkerId: string;
endMarkerId: string;
error: string | null;
/** Resolves true once the marker exists, so the input only clears on success. */
onAdd: (message: string) => Promise<boolean>;
onSelect: (id: number) => void;
onDelete: (id: number) => void;
className?: string;
};
const MarkerList = ({ markers, startMarkerId, endMarkerId, error, onAdd, onSelect, onDelete, className }: Props) => {
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const handleAdd = async () => {
if (!message.trim() || busy) return;
setBusy(true);
if (await onAdd(message.trim())) setMessage("");
setBusy(false);
};
// Either of the two may have been picked first, so the lit run is bounded by
// whichever indices come first/last rather than by start/end.
const startIndex = markers.findIndex((m) => String(m.id) === startMarkerId);
const endIndex = markers.findIndex((m) => String(m.id) === endMarkerId);
const hasRange = startIndex !== -1 && endIndex !== -1;
const rangeFrom = hasRange ? Math.min(startIndex, endIndex) : -1;
const rangeTo = hasRange ? Math.max(startIndex, endIndex) : -1;
// The rail segment sitting between marker `i` and the one after it.
const railSegment = (i: number): RailSegment => {
if (i < 0 || i >= markers.length - 1) return "none";
return hasRange && i >= rangeFrom && i < rangeTo ? "lit" : "plain";
};
return (
<Box className={clsx("p-5 flex flex-col gap-4", className)}>
<div className="flex items-center justify-between gap-3">
<CardHeader icon={Bookmark} title="Markers" accent="accent" />
{markers.length > 0 && (
<span className="font-data text-[11px] text-muted tabular-nums">{markers.length}</span>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="What just happened?"
className={inputClass}
/>
<PrimaryButton
onClick={handleAdd}
disabled={busy || !message.trim()}
className="flex items-center gap-1.5 px-3 whitespace-nowrap"
>
<Plus size={15} />
Add
</PrimaryButton>
</div>
{error && <p className="text-sm text-error">{error}</p>}
</div>
{markers.length === 0 ? (
<p className="text-sm text-muted">
Nothing marked yet. Markers stack up here, newest first — pick two to save the section between them.
</p>
) : (
/* py-0.5 keeps the scroll container from clipping the selection
ring on the first and last rows. */
<ul className="-mr-1 flex max-h-[26rem] flex-col overflow-y-auto py-0.5 pr-1">
{markers.map((m, i) => (
<MarkerRow
key={m.id}
marker={m}
role={String(m.id) === startMarkerId ? "start"
: String(m.id) === endMarkerId ? "end"
: hasRange && i > rangeFrom && i < rangeTo ? "between"
: null}
railAbove={railSegment(i - 1)}
railBelow={railSegment(i)}
onSelect={() => onSelect(m.id)}
onDelete={() => onDelete(m.id)}
/>
))}
</ul>
)}
</Box>
);
};
export default MarkerList;

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import { Radio, Check, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import clsx from "clsx";
import Box from "../components/Box.tsx";
import CardHeader from "../components/CardHeader.tsx";
import MarkerList from "../components/stream/MarkerList.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import { useAuth } from "../auth/useAuth.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
@@ -9,7 +11,7 @@ import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts";
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
import { formatLocalDate, stringToDate } from "../utils/utils.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
@@ -22,15 +24,6 @@ type TrackedJob = {
errorOutput: string | 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-terracotta/10" : "bg-olive/10")}>
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
</div>
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
</div>
);
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
@@ -63,11 +56,8 @@ const StreamControl = () => {
const { user } = useAuth();
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 [clipDuration, setClipDuration] = useState(30);
@@ -103,16 +93,10 @@ const StreamControl = () => {
.catch(() => {});
}, [user, streamStatus?.id]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [streamDetails?.startDate]);
const refreshMarkers = () => {
getMarkers()
.then((data) => setMarkers(data
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
.catch(() => {});
};
@@ -149,18 +133,15 @@ const StreamControl = () => {
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
};
const handleAddMarker = async () => {
if (!markerMessage.trim() || markerBusy) return;
setMarkerBusy(true);
const handleAddMarker = async (message: string) => {
setMarkerError(null);
try {
await createMarker(markerMessage.trim());
setMarkerMessage("");
await createMarker(message);
refreshMarkers();
return true;
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
} finally {
setMarkerBusy(false);
return false;
}
};
@@ -179,7 +160,7 @@ const StreamControl = () => {
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Tap two markers above to pick a start and end");
setMarkerSaveError("Pick a start and an end marker from the timeline");
return;
}
try {
@@ -210,7 +191,7 @@ const StreamControl = () => {
}
};
const handleChipClick = (id: number) => {
const handleSelectMarker = (id: number) => {
const idStr = String(id);
setMarkerSaveError(null);
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
@@ -228,10 +209,6 @@ const StreamControl = () => {
);
}
const elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
: null;
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
@@ -259,97 +236,30 @@ const StreamControl = () => {
</div>
</div>
{isStreaming && elapsed ? (
<div className="text-right">
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
<p className={labelClass}>elapsed</p>
</div>
) : (
<Radio size={28} className="text-inactive" />
)}
<Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
</Box>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-olive flex-shrink-0" />
<input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
className={inputClass}
/>
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker
</PrimaryButton>
</div>
{markerError && <p className="text-sm text-error">{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);
return (
<div key={m.id} className="group relative flex-shrink-0">
<button
onClick={() => handleChipClick(m.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-terracotta bg-selected ring-1 ring-terracotta",
isEnd && "border-olive bg-selected ring-1 ring-olive",
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover"
)}
>
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
<span className="font-data text-[11px] text-muted">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-terracotta font-medium"> · start</span>}
{isEnd && <span className="text-olive font-medium"> · end</span>}
</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-muted hover:bg-error text-on-accent transition-colors duration-150"
>
<X size={10} />
</button>
</div>
);
})}
</div>
)}
</Box>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
{/* Marker timeline. First on narrow screens, where marking
something as it happens beats the action forms for urgency. */}
<MarkerList
className="lg:order-last lg:sticky lg:top-6"
markers={markers}
startMarkerId={startMarkerId}
endMarkerId={endMarkerId}
error={markerError}
onAdd={handleAddMarker}
onSelect={handleSelectMarker}
onDelete={handleDeleteMarker}
/>
{/* Forefront actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<Box 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-error">{clipError}</p>}
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
</Box>
<div className="flex flex-col gap-5">
<Box 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-muted">Add at least two markers above to save a section between them.</p>
<p className="text-sm text-muted">Add at least two markers to save a section between them.</p>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
@@ -374,19 +284,37 @@ const StreamControl = () => {
)}
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
</Box>
</div>
<Box className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-muted">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} />)}
<Box 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>
)}
</Box>
</>
<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-error">{clipError}</p>}
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
</Box>
<Box className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-muted">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>
)}
</Box>
</div>
</div>
)}
</div>
);

View File

@@ -21,6 +21,12 @@ function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOption
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
}
// Clock time only, no date — markers within a stream are all from the same day,
// so the date would just be noise beside them.
function formatLocalTime(dateString: string): string {
return stringToDate(dateString).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function dateToTimeAgo(date: Date): string {
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const units: [number, string][] = [
@@ -40,4 +46,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };