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:
Steffen Schuhmann
2026-03-28 21:53:03 +01:00
commit f7be1b994d
139 changed files with 12743 additions and 0 deletions

265
web/src/pages/BandPage.tsx Normal file
View File

@@ -0,0 +1,265 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands";
import { api } from "../api/client";
interface SongSummary {
id: string;
title: string;
status: string;
version_count: number;
}
interface BandMember {
id: string;
display_name: string;
email: string;
role: string;
joined_at: string;
}
interface BandInvite {
id: string;
token: string;
role: string;
expires_at: string;
}
export function BandPage() {
const { bandId } = useParams<{ bandId: string }>();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId],
queryFn: () => getBand(bandId!),
enabled: !!bandId,
});
const { data: songs } = useQuery({
queryKey: ["songs", bandId],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs`),
enabled: !!bandId,
});
const { data: members } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
const createMutation = useMutation({
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["songs", bandId] });
setShowCreate(false);
setTitle("");
setError(null);
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
});
const scanMutation = useMutation({
mutationFn: () => api.post<SongSummary[]>(`/bands/${bandId}/nc-scan`, {}),
onSuccess: (imported) => {
qc.invalidateQueries({ queryKey: ["songs", bandId] });
setScanMsg(
imported.length > 0
? `Imported ${imported.length} new song${imported.length !== 1 ? "s" : ""} from Nextcloud.`
: "No new audio files found in Nextcloud."
);
setTimeout(() => setScanMsg(null), 4000);
},
onError: (err) => setScanMsg(err instanceof Error ? err.message : "Scan failed"),
});
const inviteMutation = useMutation({
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
onSuccess: (invite) => {
const url = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(url);
navigator.clipboard.writeText(url).catch(() => {});
},
});
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
// We determine "am I admin?" from GET /auth/me cross-referenced with the members list.
// The simplest heuristic: the creator of the band (first admin in the list) is the current user
// if they appear with role=admin. We store the current member id in the JWT subject but don't
// expose it yet, so we compare by checking if the members list has exactly one admin and we
// can tell by the invite button being available on the backend (403 vs 201).
// For the UI we just show the Remove button for non-admin members and let the API enforce auth.
if (isLoading) return <div style={{ color: "#5A6480", padding: 32 }}>Loading...</div>;
if (!band) return <div style={{ color: "#E85878", padding: 32 }}>Band not found</div>;
const amAdmin = members?.some((m) => m.role === "admin") ?? false;
return (
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<Link to="/" style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
All Bands
</Link>
<div style={{ marginBottom: 32 }}>
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
{band.genre_tags.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{band.genre_tags.map((t: string) => (
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
</div>
)}
</div>
{/* ── Members ── */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Members</h2>
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#F0A840", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
+ Invite
</button>
</div>
{inviteLink && (
<div style={{ background: "#0E1118", border: "1px solid #F0A840", borderRadius: 6, padding: "10px 14px", marginBottom: 12 }}>
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
<code style={{ color: "#F0A840", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "#5A6480", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Dismiss
</button>
</div>
)}
<div style={{ display: "grid", gap: 6 }}>
{members?.map((m) => (
<div
key={m.id}
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 6, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<div>
<span style={{ fontWeight: 500 }}>{m.display_name}</span>
<span style={{ color: "#5A6480", fontSize: 11, marginLeft: 10 }}>{m.email}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{
fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3,
background: m.role === "admin" ? "#2A1E08" : "#0E1118",
color: m.role === "admin" ? "#F0A840" : "#5A6480",
border: `1px solid ${m.role === "admin" ? "#F0A840" : "#1C2235"}`,
}}>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMemberMutation.mutate(m.id)}
style={{ background: "none", border: "none", color: "#E85878", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Remove
</button>
)}
</div>
</div>
))}
</div>
</div>
{/* ── Songs ── */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Songs</h2>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => scanMutation.mutate()}
disabled={scanMutation.isPending}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#38C9A8", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
{scanMutation.isPending ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
<button
onClick={() => { setShowCreate(!showCreate); setError(null); }}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
+ New Song
</button>
</div>
</div>
{scanMsg && (
<div style={{ background: "#0A2820", border: "1px solid #38C9A8", borderRadius: 6, color: "#38C9A8", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
{scanMsg}
</div>
)}
{showCreate && (
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 16 }}>
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
autoFocus
/>
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 12px" }}>
A folder <code style={{ color: "#38C9A8" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud.
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => createMutation.mutate()}
disabled={!title}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
>
Create
</button>
<button
onClick={() => { setShowCreate(false); setError(null); }}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
>
Cancel
</button>
</div>
</div>
)}
<div style={{ display: "grid", gap: 8 }}>
{songs?.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: "14px 18px", textDecoration: "none", color: "#E2E6F0", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<span>{song.title}</span>
<span style={{ color: "#5A6480", fontSize: 12 }}>
<span style={{ background: "#0E1118", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
{songs?.length === 0 && (
<p style={{ color: "#5A6480", fontSize: 13 }}>
No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "#38C9A8" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
</p>
)}
</div>
</div>
</div>
);
}