Files
rehearshalhub/web/src/pages/SessionPage.tsx
Mistral Vibe 013a2fc2d6 Fix Invalid Date for datetime strings from API
The API returns dates as "2024-12-11T00:00:00" (datetime, no timezone),
not bare "2024-12-11". Appending T12:00:00 directly produced an invalid
string. Use .slice(0, 10) to extract the date part first before parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:40:38 +02:00

204 lines
8.7 KiB
TypeScript

import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
interface SongSummary {
id: string;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SessionDetail {
id: string;
band_id: string;
date: string;
label: string | null;
notes: string | null;
recording_count: number;
songs: SongSummary[];
}
function formatDate(iso: string): string {
const d = new Date(iso.slice(0, 10) + "T12:00:00");
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
}
function computeWaveBars(seed: string): number[] {
let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337);
return Array.from({ length: 14 }, () => {
s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0;
return Math.max(15, Math.floor((s / 0xffffffff) * 100));
});
}
function MiniWaveBars({ seed }: { seed: string }) {
const bars = useMemo(() => computeWaveBars(seed), [seed]);
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: "1.5px", height: 18, width: 34, flexShrink: 0 }}>
{bars.map((h, i) => (
<div key={i} style={{ width: 2, background: "rgba(255,255,255,0.11)", borderRadius: 1, height: `${h}%` }} />
))}
</div>
);
}
export function SessionPage() {
const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>();
const qc = useQueryClient();
const [editingMeta, setEditingMeta] = useState(false);
const [labelInput, setLabelInput] = useState("");
const [notesInput, setNotesInput] = useState("");
const { data: session, isLoading } = useQuery({
queryKey: ["session", sessionId],
queryFn: () => api.get<SessionDetail>(`/bands/${bandId}/sessions/${sessionId}`),
enabled: !!sessionId && !!bandId,
});
const updateMutation = useMutation({
mutationFn: (data: { label?: string; notes?: string }) =>
api.patch(`/bands/${bandId}/sessions/${sessionId}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["session", sessionId] });
setEditingMeta(false);
},
});
function startEdit() {
setLabelInput(session?.label ?? "");
setNotesInput(session?.notes ?? "");
setEditingMeta(true);
}
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
if (!session) return <div style={{ color: "var(--danger)", padding: 32 }}>Session not found</div>;
return (
<div style={{ padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<Link
to={`/bands/${bandId}`}
style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}
>
Library
</Link>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12 }}>
<div>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px", fontSize: 22 }}>
{formatDate(session.date)}
</h1>
{session.label && (
<p style={{ color: "var(--teal)", margin: "0 0 4px", fontSize: 14 }}>{session.label}</p>
)}
<p style={{ color: "var(--text-muted)", margin: 0, fontSize: 12 }}>
{session.recording_count} recording{session.recording_count !== 1 ? "s" : ""}
</p>
</div>
{!editingMeta && (
<button
onClick={startEdit}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "4px 12px", fontSize: 11, whiteSpace: "nowrap" }}
>
Edit
</button>
)}
</div>
{session.notes && !editingMeta && (
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 8, lineHeight: 1.5 }}>{session.notes}</p>
)}
{editingMeta && (
<div style={{ marginTop: 12, background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 16 }}>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 4 }}>LABEL</label>
<input
value={labelInput}
onChange={(e) => setLabelInput(e.target.value)}
placeholder="e.g. pre-gig warmup"
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box", marginBottom: 10 }}
/>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 4 }}>NOTES</label>
<textarea
value={notesInput}
onChange={(e) => setNotesInput(e.target.value)}
rows={3}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box", resize: "vertical", fontFamily: "inherit" }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button
onClick={() => updateMutation.mutate({ label: labelInput || undefined, notes: notesInput || undefined })}
disabled={updateMutation.isPending}
style={{ background: "var(--teal)", border: "none", borderRadius: 6, color: "var(--bg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
Save
</button>
<button
onClick={() => setEditingMeta(false)}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Recording list */}
<div style={{ display: "grid", gap: 8 }}>
{session.songs.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "var(--bg-subtle)",
border: "1px solid var(--border-subtle)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
{song.global_key && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span>
)}
{song.global_bpm && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span>
)}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<MiniWaveBars seed={song.id} />
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</div>
</div>
</Link>
))}
{session.songs.length === 0 && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No recordings in this session yet.</p>
)}
</div>
</div>
</div>
);
}