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. */ + + )} +
    + ); +}; + +export default MarkerList; diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx index b3ff5d0..887028b 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, Plus, 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 { formatLocalDate, formatLocalTime, 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 ( @@ -59,77 +52,12 @@ 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 [markers, setMarkers] = useState([]); - const [markerMessage, setMarkerMessage] = useState(""); - const [markerBusy, setMarkerBusy] = useState(false); const [markerError, setMarkerError] = useState(null); const [clipDuration, setClipDuration] = useState(30); @@ -205,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; } }; @@ -266,7 +191,7 @@ const StreamControl = () => { } }; - const handleChipClick = (id: number) => { + const handleSelectMarker = (id: number) => { const idStr = String(id); setMarkerSaveError(null); if (startMarkerId === idStr) { setStartMarkerId(""); return; } @@ -287,20 +212,6 @@ const StreamControl = () => { 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

    @@ -332,59 +243,16 @@ const StreamControl = () => {
    {/* Marker timeline. First on narrow screens, where marking something as it happens beats the action forms for urgency. */} - -
    - - {markers.length > 0 && ( - {markers.length} - )} -
    - -
    -
    - 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 */}