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

View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { api } from "../api/client";
interface InviteInfo {
id: string;
band_id: string;
token: string;
role: string;
expires_at: string;
used_at: string | null;
}
export function InvitePage() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [accepting, setAccepting] = useState(false);
const [done, setDone] = useState(false);
const isLoggedIn = !!localStorage.getItem("rh_token");
useEffect(() => {
if (!token) return;
api.get<InviteInfo>(`/invites/${token}`)
.then(setInvite)
.catch((err) => setError(err instanceof Error ? err.message : "Invalid invite"));
}, [token]);
async function accept() {
if (!token) return;
setAccepting(true);
try {
await api.post(`/invites/${token}/accept`, {});
setDone(true);
setTimeout(() => navigate("/"), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to accept invite");
} finally {
setAccepting(false);
}
}
function goLogin() {
navigate(`/login?next=/invite/${token}`);
}
return (
<div style={{ background: "#080A0E", minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", color: "#E2E6F0" }}>
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 12, padding: 40, maxWidth: 420, width: "100%", textAlign: "center" }}>
<h1 style={{ color: "#F0A840", fontFamily: "monospace", marginBottom: 8, fontSize: 22 }}> RehearsalHub</h1>
<p style={{ color: "#5A6480", fontSize: 13, marginBottom: 28 }}>Band invite</p>
{error && (
<div style={{ background: "#1A0810", border: "1px solid #E85878", borderRadius: 6, padding: "12px 16px", color: "#E85878", fontSize: 13, marginBottom: 20 }}>
{error}
</div>
)}
{done && (
<div style={{ color: "#38C9A8", fontSize: 14 }}>
Joined! Redirecting
</div>
)}
{!done && invite && (
<>
<p style={{ color: "#E2E6F0", fontSize: 15, marginBottom: 6 }}>
You've been invited to join a band as <strong style={{ color: "#F0A840" }}>{invite.role}</strong>.
</p>
<p style={{ color: "#5A6480", fontSize: 12, marginBottom: 28 }}>
Expires {new Date(invite.expires_at).toLocaleDateString()}
{invite.used_at && " · Already used"}
</p>
{isLoggedIn ? (
<button
onClick={accept}
disabled={accepting || !!invite.used_at}
style={{ width: "100%", background: "#F0A840", border: "none", borderRadius: 8, color: "#080A0E", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }}
>
{accepting ? "Joining…" : "Accept Invite"}
</button>
) : (
<div>
<p style={{ color: "#5A6480", fontSize: 13, marginBottom: 16 }}>Log in or register to accept this invite.</p>
<button
onClick={goLogin}
style={{ width: "100%", background: "#F0A840", border: "none", borderRadius: 8, color: "#080A0E", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }}
>
Log in / Register
</button>
</div>
)}
</>
)}
{!done && !invite && !error && (
<p style={{ color: "#5A6480" }}>Loading invite</p>
)}
</div>
</div>
);
}