From 7dacb7a53ae4ca80d6bafab26e7af3311babeeea Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 16 Sep 2026 20:11:12 +0100 Subject: [PATCH] feat(frontend): make markers list vertical in stream page (#46) Resolves #38 Reviewed-on: https://gitea.defaoite.dev/dylan/vod-system/pulls/46 --- frontend/src/components/CardHeader.tsx | 18 ++ frontend/src/components/stream/MarkerList.tsx | 173 ++++++++++++++++ frontend/src/pages/StreamControl.tsx | 184 ++++++------------ frontend/src/utils/utils.ts | 8 +- 4 files changed, 254 insertions(+), 129 deletions(-) create mode 100644 frontend/src/components/CardHeader.tsx create mode 100644 frontend/src/components/stream/MarkerList.tsx diff --git a/frontend/src/components/CardHeader.tsx b/frontend/src/components/CardHeader.tsx new file mode 100644 index 0000000..66e0fb4 --- /dev/null +++ b/frontend/src/components/CardHeader.tsx @@ -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) => ( +
+
+ +
+

{title}

+
+); + +export default CardHeader; diff --git a/frontend/src/components/stream/MarkerList.tsx b/frontend/src/components/stream/MarkerList.tsx new file mode 100644 index 0000000..5b8037d --- /dev/null +++ b/frontend/src/components/stream/MarkerList.tsx @@ -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) => ( +
  • + {/* Timeline rail: a dot per marker, with the segment between the chosen + start and end lit so the selected section reads as one run. */} +
    + + + +
    + + {/* The rail spans the full row, so the breathing room goes on the button — + the line stays continuous while neighbouring rings no longer touch. */} + + + +
  • +); + +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; + 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 ( + +
    + + {markers.length > 0 && ( + {markers.length} + )} +
    + +
    +
    + setMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder="What just happened?" + className={inputClass} + /> + + + Add + +
    + {error &&

    {error}

    } +
    + + {markers.length === 0 ? ( +

    + Nothing marked yet. Markers stack up here, newest first — pick two to save the section between them. +

    + ) : ( + /* py-0.5 keeps the scroll container from clipping the selection + ring on the first and last rows. */ +
      + {markers.map((m, i) => ( + rangeFrom && i < rangeTo ? "between" + : null} + railAbove={railSegment(i - 1)} + railBelow={railSegment(i)} + onSelect={() => onSelect(m.id)} + onDelete={() => onDelete(m.id)} + /> + ))} +
    + )} +
    + ); +}; + +export default MarkerList; diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx index 86b8c05..6002abc 100644 --- a/frontend/src/pages/StreamControl.tsx +++ b/frontend/src/pages/StreamControl.tsx @@ -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" }) => ( -
    -
    - -
    -

    {title}

    -
    -); - const JobStatus = ({ job }: { job: TrackedJob }) => { if (job.state === 'FAILED') { return ( @@ -63,11 +56,8 @@ const StreamControl = () => { const { user } = useAuth(); const [streamStatus, setStreamStatus] = useState(null); const [streamDetails, setStreamDetails] = useState(null); - const [now, setNow] = useState(Date.now()); const [markers, setMarkers] = useState([]); - const [markerMessage, setMarkerMessage] = useState(""); - const [markerBusy, setMarkerBusy] = useState(false); const [markerError, setMarkerError] = useState(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 = () => { - {isStreaming && elapsed ? ( -
    -

    {elapsed}

    -

    elapsed

    -
    - ) : ( - - )} + {isStreaming && ( - <> - {/* Quick mark toolbar */} - -
    - - setMarkerMessage(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAddMarker()} - placeholder="What just happened?" - className={inputClass} - /> - - Add Marker - -
    - {markerError &&

    {markerError}

    } - - {markers.length > 0 && ( -
    - {markers.map((m) => { - const isStart = startMarkerId === String(m.id); - const isEnd = endMarkerId === String(m.id); - return ( -
    - - -
    - ); - })} -
    - )} -
    +
    + {/* Marker timeline. First on narrow screens, where marking + something as it happens beats the action forms for urgency. */} + {/* Forefront actions */} -
    - - -
    - - setClipDuration(Number(e.target.value))} - className={inputClass} - /> -
    - setClipTitle(e.target.value)} className={inputClass} /> -