feat(web): SessionPage — rehearsal date detail view
Shows date, optional label/notes (admin-editable), and a flat list of all recordings from that session. Each recording links to SongPage and shows tags, key, BPM chips inline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { ThemeProvider, useTheme } from "./theme";
|
|||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { HomePage } from "./pages/HomePage";
|
import { HomePage } from "./pages/HomePage";
|
||||||
import { BandPage } from "./pages/BandPage";
|
import { BandPage } from "./pages/BandPage";
|
||||||
|
import { SessionPage } from "./pages/SessionPage";
|
||||||
import { SongPage } from "./pages/SongPage";
|
import { SongPage } from "./pages/SongPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { InvitePage } from "./pages/InvitePage";
|
import { InvitePage } from "./pages/InvitePage";
|
||||||
@@ -63,6 +64,14 @@ export default function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/bands/:bandId/sessions/:sessionId"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<SessionPage />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bands/:bandId/songs/:songId"
|
path="/bands/:bandId/songs/:songId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
181
web/src/pages/SessionPage.tsx
Normal file
181
web/src/pages/SessionPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState } 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);
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", 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 }}
|
||||||
|
>
|
||||||
|
← Back to Band
|
||||||
|
</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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, 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-inset)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
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={{ 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>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{session.songs.length === 0 && (
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No recordings in this session yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user