From a4d11f41781affcebced718dfe21f86d556e96a8 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 16 Sep 2026 20:04:21 +0100 Subject: [PATCH] make markers vertical --- frontend/src/pages/StreamControl.tsx | 234 +++++++++++++++++---------- frontend/src/utils/utils.ts | 8 +- 2 files changed, 154 insertions(+), 88 deletions(-) diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx index 86b8c05..b3ff5d0 100644 --- a/frontend/src/pages/StreamControl.tsx +++ b/frontend/src/pages/StreamControl.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react"; +import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, Plus, X } from "lucide-react"; import clsx from "clsx"; import Box from "../components/Box.tsx"; import PrimaryButton from "../components/buttons/PrimaryButton.tsx"; @@ -9,7 +9,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, formatLocalTime, 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]"; @@ -59,11 +59,73 @@ const JobStatus = ({ job }: { job: TrackedJob }) => { ); }; +type RailSegment = "none" | "plain" | "lit"; + +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 railClass = (segment: RailSegment) => clsx( + "w-px", + segment === "lit" && "bg-terracotta", + segment === "plain" && "bg-inactive-strong/60", + segment === "none" && "bg-transparent" +); + +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. */} +
    + + + +
    + + + + +
  • +); + 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(""); @@ -103,16 +165,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(() => {}); }; @@ -179,7 +235,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 { @@ -228,13 +284,23 @@ 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); + // Markers are listed newest first, and 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): "none" | "plain" | "lit" => { + if (i < 0 || i >= markers.length - 1) return "none"; + return hasRange && i >= rangeFrom && i < rangeTo ? "lit" : "plain"; + }; + return (

    Stream Control

    @@ -259,75 +325,69 @@ 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 - +
    + {/* Marker timeline. First on narrow screens, where marking + something as it happens beats the action forms for urgency. */} + +
    + + {markers.length > 0 && ( + {markers.length} + )}
    - {markerError &&

    {markerError}

    } - {markers.length > 0 && ( -
    - {markers.map((m) => { - const isStart = startMarkerId === String(m.id); - const isEnd = endMarkerId === String(m.id); - return ( -
    - - -
    - ); - })} +
    +
    + setMarkerMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddMarker()} + placeholder="What just happened?" + className={inputClass} + /> + + + Add +
    + {markerError &&

    {markerError}

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

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

    + ) : ( +
      + {markers.map((m, i) => ( + rangeFrom && i < rangeTo ? "between" + : null} + railAbove={railSegment(i - 1)} + railBelow={railSegment(i)} + onSelect={() => handleChipClick(m.id)} + onDelete={() => handleDeleteMarker(m.id)} + /> + ))} +
    )} {/* Forefront actions */} -
    +
    @@ -349,7 +409,7 @@ const StreamControl = () => { {markers.length < 2 ? ( -

    Add at least two markers above to save a section between them.

    +

    Add at least two markers to save a section between them.

    ) : ( <>
    @@ -374,19 +434,19 @@ const StreamControl = () => { )} Save -
    - - - {jobs.length === 0 ? ( -

    Clip and save actions will show up here.

    - ) : ( -
    - {jobs.map((job) => )} -
    - )} -
    - + + + {jobs.length === 0 ? ( +

    Clip and save actions will show up here.

    + ) : ( +
    + {jobs.map((job) => )} +
    + )} +
    +
    +
    )}
    ); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 7885f8d..7c225ea 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -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 };