extract MarkerList out
This commit is contained in:
18
frontend/src/components/CardHeader.tsx
Normal file
18
frontend/src/components/CardHeader.tsx
Normal 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;
|
||||||
173
frontend/src/components/stream/MarkerList.tsx
Normal file
173
frontend/src/components/stream/MarkerList.tsx
Normal 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;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 clsx from "clsx";
|
||||||
import Box from "../components/Box.tsx";
|
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 PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.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 { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||||
import { getJob } from "../utils/api/jobs.ts";
|
import { getJob } from "../utils/api/jobs.ts";
|
||||||
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.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 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]";
|
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
||||||
@@ -22,15 +24,6 @@ type TrackedJob = {
|
|||||||
errorOutput: string | null;
|
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 }) => {
|
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
return (
|
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) => (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StreamControl = () => {
|
const StreamControl = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
||||||
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
||||||
|
|
||||||
const [markers, setMarkers] = useState<Marker[]>([]);
|
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||||
const [markerMessage, setMarkerMessage] = useState("");
|
|
||||||
const [markerBusy, setMarkerBusy] = useState(false);
|
|
||||||
const [markerError, setMarkerError] = useState<string | null>(null);
|
const [markerError, setMarkerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [clipDuration, setClipDuration] = useState(30);
|
const [clipDuration, setClipDuration] = useState(30);
|
||||||
@@ -205,18 +133,15 @@ const StreamControl = () => {
|
|||||||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMarker = async () => {
|
const handleAddMarker = async (message: string) => {
|
||||||
if (!markerMessage.trim() || markerBusy) return;
|
|
||||||
setMarkerBusy(true);
|
|
||||||
setMarkerError(null);
|
setMarkerError(null);
|
||||||
try {
|
try {
|
||||||
await createMarker(markerMessage.trim());
|
await createMarker(message);
|
||||||
setMarkerMessage("");
|
|
||||||
refreshMarkers();
|
refreshMarkers();
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
||||||
} finally {
|
return false;
|
||||||
setMarkerBusy(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,7 +191,7 @@ const StreamControl = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChipClick = (id: number) => {
|
const handleSelectMarker = (id: number) => {
|
||||||
const idStr = String(id);
|
const idStr = String(id);
|
||||||
setMarkerSaveError(null);
|
setMarkerSaveError(null);
|
||||||
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
||||||
@@ -287,20 +212,6 @@ const StreamControl = () => {
|
|||||||
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
||||||
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
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 (
|
return (
|
||||||
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||||
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
|
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
|
||||||
@@ -332,59 +243,16 @@ const StreamControl = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
|
<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
|
{/* Marker timeline. First on narrow screens, where marking
|
||||||
something as it happens beats the action forms for urgency. */}
|
something as it happens beats the action forms for urgency. */}
|
||||||
<Box className="lg:order-last lg:sticky lg:top-6 p-5 flex flex-col gap-4">
|
<MarkerList
|
||||||
<div className="flex items-center justify-between gap-3">
|
className="lg:order-last lg:sticky lg:top-6"
|
||||||
<CardHeader icon={Bookmark} title="Markers" accent="accent" />
|
markers={markers}
|
||||||
{markers.length > 0 && (
|
startMarkerId={startMarkerId}
|
||||||
<span className="font-data text-[11px] text-muted tabular-nums">{markers.length}</span>
|
endMarkerId={endMarkerId}
|
||||||
)}
|
error={markerError}
|
||||||
</div>
|
onAdd={handleAddMarker}
|
||||||
|
onSelect={handleSelectMarker}
|
||||||
<div className="flex flex-col gap-2">
|
onDelete={handleDeleteMarker}
|
||||||
<div className="flex gap-2">
|
/>
|
||||||
<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="flex items-center gap-1.5 px-3 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Plus size={15} />
|
|
||||||
Add
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
{markerError && <p className="text-sm text-error">{markerError}</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>
|
|
||||||
) : (
|
|
||||||
<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={() => handleChipClick(m.id)}
|
|
||||||
onDelete={() => handleDeleteMarker(m.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Forefront actions */}
|
{/* Forefront actions */}
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|||||||
Reference in New Issue
Block a user