Initial commit: RehearsalHub POC
Full-stack self-hosted band rehearsal platform: Backend (FastAPI + SQLAlchemy 2.0 async): - Auth with JWT (register, login, /me, settings) - Band management with Nextcloud folder integration - Song management with audio version tracking - Nextcloud scan to auto-import audio files - Band membership with link-based invite system - Song comments - Audio analysis worker (BPM, key, loudness, waveform) - Nextcloud activity watcher for auto-import - WebSocket support for real-time annotation updates - Alembic migrations (0001–0003) - Repository pattern, Ruff + mypy configured Frontend (React 18 + Vite + TypeScript strict): - Login/register page with post-login redirect - Home page with band list and creation form - Band page with member panel, invite link, song list, NC scan - Song page with waveform player, annotations, comment thread - Settings page for per-user Nextcloud credentials - Invite acceptance page (/invite/:token) - ESLint v9 flat config + TypeScript strict mode Infrastructure: - Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx - nginx reverse proxy for static files + /api/ proxy - make check runs all linters before docker compose build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
229
web/src/pages/SongPage.tsx
Normal file
229
web/src/pages/SongPage.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { listAnnotations, addReaction } from "../api/annotations";
|
||||
import { useVersionWebSocket } from "../hooks/useWebSocket";
|
||||
import { useWaveform } from "../hooks/useWaveform";
|
||||
import type { Annotation } from "../api/annotations";
|
||||
|
||||
interface SongComment {
|
||||
id: string;
|
||||
song_id: string;
|
||||
body: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function SongPage() {
|
||||
const { bandId, songId } = useParams<{ bandId: string; songId: string }>();
|
||||
const qc = useQueryClient();
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
|
||||
const { data: versions } = useQuery({
|
||||
queryKey: ["versions", songId],
|
||||
queryFn: () => api.get<{ id: string; version_number: number; label: string | null; analysis_status: string }[]>(`/songs/${songId}/versions`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
|
||||
|
||||
const { data: annotations } = useQuery({
|
||||
queryKey: ["annotations", activeVersion],
|
||||
queryFn: () => listAnnotations(activeVersion!),
|
||||
enabled: !!activeVersion,
|
||||
});
|
||||
|
||||
const { isPlaying, currentTime, play, pause, seekTo } = useWaveform(waveformRef, {
|
||||
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
||||
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
||||
});
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: ["comments", songId],
|
||||
queryFn: () => api.get<SongComment[]>(`/songs/${songId}/comments`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: (body: string) => api.post(`/songs/${songId}/comments`, { body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["comments", songId] });
|
||||
setCommentBody("");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCommentMutation = useMutation({
|
||||
mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }),
|
||||
});
|
||||
|
||||
const invalidateAnnotations = useCallback(
|
||||
() => qc.invalidateQueries({ queryKey: ["annotations", activeVersion] }),
|
||||
[qc, activeVersion]
|
||||
);
|
||||
|
||||
useVersionWebSocket(activeVersion, {
|
||||
"annotation.created": invalidateAnnotations,
|
||||
"annotation.updated": invalidateAnnotations,
|
||||
"annotation.deleted": invalidateAnnotations,
|
||||
"reaction.added": invalidateAnnotations,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}>
|
||||
<Link to={`/bands/${bandId}`} style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 16 }}>
|
||||
← Back to Band
|
||||
</Link>
|
||||
|
||||
{/* Version selector */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
|
||||
{versions?.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
style={{
|
||||
background: v.id === activeVersion ? "#2A1E08" : "#131720",
|
||||
border: `1px solid ${v.id === activeVersion ? "#F0A840" : "#1C2235"}`,
|
||||
borderRadius: 6, padding: "6px 14px", color: v.id === activeVersion ? "#F0A840" : "#5A6480",
|
||||
cursor: "pointer", fontSize: 12, fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
v{v.version_number} {v.label ?? ""} · {v.analysis_status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Waveform */}
|
||||
<div
|
||||
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: "16px 16px 8px", marginBottom: 16 }}
|
||||
onClick={(_e) => {
|
||||
// TODO: seek on click (needs duration from wavesurfer)
|
||||
}}
|
||||
>
|
||||
<div ref={waveformRef} />
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={isPlaying ? pause : play}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, padding: "6px 18px", cursor: "pointer", fontWeight: 600, color: "#080A0E" }}
|
||||
>
|
||||
{isPlaying ? "⏸ Pause" : "▶ Play"}
|
||||
</button>
|
||||
<span style={{ color: "#5A6480", fontSize: 12, alignSelf: "center" }}>
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations */}
|
||||
<div style={{ display: "grid", gap: 8, marginBottom: 32 }}>
|
||||
{annotations?.map((a) => (
|
||||
<AnnotationCard key={a.id} annotation={a} onSeek={seekTo} versionId={activeVersion!} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: 14, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 14 }}>COMMENTS</h2>
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginBottom: 16 }}>
|
||||
{comments?.map((c) => (
|
||||
<div key={c.id} style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: "12px 16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: "#E2E6F0" }}>{c.author_name}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ color: "#38496A", fontSize: 11 }}>{new Date(c.created_at).toLocaleString()}</span>
|
||||
<button
|
||||
onClick={() => deleteCommentMutation.mutate(c.id)}
|
||||
style={{ background: "none", border: "none", color: "#38496A", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "#C8CDD8", lineHeight: 1.5 }}>{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
{comments?.length === 0 && (
|
||||
<p style={{ color: "#38496A", fontSize: 13 }}>No comments yet. Be the first.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<textarea
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Add a comment…"
|
||||
rows={2}
|
||||
style={{ flex: 1, padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", fontSize: 13, resize: "vertical", fontFamily: "inherit" }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => commentBody.trim() && addCommentMutation.mutate(commentBody.trim())}
|
||||
disabled={!commentBody.trim() || addCommentMutation.isPending}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "0 18px", fontWeight: 600, fontSize: 13, alignSelf: "stretch" }}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnotationCard({ annotation: a, onSeek, versionId }: { annotation: Annotation; onSeek: (t: number) => void; versionId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const reactionMutation = useMutation({
|
||||
mutationFn: (emoji: string) => addReaction(a.id, emoji),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["annotations", versionId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: 14 }}>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => onSeek(a.timestamp_ms / 1000)}
|
||||
style={{ background: "#2A1E08", border: "1px solid #F0A840", borderRadius: 4, color: "#F0A840", cursor: "pointer", fontSize: 10, padding: "2px 8px", fontFamily: "monospace" }}
|
||||
>
|
||||
{formatTime(a.timestamp_ms / 1000)}
|
||||
{a.range_end_ms != null && ` → ${formatTime(a.range_end_ms / 1000)}`}
|
||||
</button>
|
||||
<span style={{ color: "#5A6480", fontSize: 11 }}>{a.type}</span>
|
||||
{a.label && <span style={{ color: "#38C9A8", fontSize: 11 }}>{a.label}</span>}
|
||||
{a.tags.map((t) => (
|
||||
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
{a.body && <p style={{ color: "#E2E6F0", margin: 0, fontSize: 13 }}>{a.body}</p>}
|
||||
{a.range_analysis && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 12, fontSize: 11, color: "#5A6480" }}>
|
||||
{a.range_analysis.bpm && <span>♩ {a.range_analysis.bpm.toFixed(1)} BPM</span>}
|
||||
{a.range_analysis.key && <span>🎵 {a.range_analysis.key}</span>}
|
||||
{a.range_analysis.avg_loudness_lufs && <span>{a.range_analysis.avg_loudness_lufs.toFixed(1)} LUFS</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 4 }}>
|
||||
{["🔥", "💡", "✅", "❓"].map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => reactionMutation.mutate(emoji)}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 4, cursor: "pointer", padding: "2px 6px", fontSize: 14 }}
|
||||
>
|
||||
{emoji}{" "}
|
||||
<span style={{ fontSize: 10, color: "#5A6480" }}>
|
||||
{a.reactions.filter((r) => r.emoji === emoji).length || ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user