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,147 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
interface MemberRead {
id: string;
display_name: string;
email: string;
nc_username: string | null;
nc_url: string | null;
nc_configured: boolean;
}
const getMe = () => api.get<MemberRead>("/auth/me");
const updateSettings = (data: {
display_name?: string;
nc_url?: string;
nc_username?: string;
nc_password?: string;
}) => api.patch<MemberRead>("/auth/me/settings", data);
// Rendered only after `me` is loaded — initializes form state directly from props.
function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
const qc = useQueryClient();
const [displayName, setDisplayName] = useState(me.display_name ?? "");
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const saveMutation = useMutation({
mutationFn: () =>
updateSettings({
display_name: displayName || undefined,
nc_url: ncUrl || undefined,
nc_username: ncUsername || undefined,
nc_password: ncPassword || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
setSaved(true);
setNcPassword("");
setError(null);
setTimeout(() => setSaved(false), 3000);
},
onError: (err) => setError(err instanceof Error ? err.message : "Save failed"),
});
return (
<>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 13, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 16 }}>PROFILE</h2>
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>DISPLAY NAME</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={inputStyle} />
<p style={{ color: "#38496A", fontSize: 11, margin: "4px 0 0" }}>{me.email}</p>
</section>
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 13, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 8 }}>NEXTCLOUD CONNECTION</h2>
<p style={{ color: "#38496A", fontSize: 12, marginBottom: 16 }}>
Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials.
</p>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: me.nc_configured ? "#38C9A8" : "#5A6480" }} />
<span style={{ fontSize: 12, color: me.nc_configured ? "#38C9A8" : "#5A6480" }}>
{me.nc_configured ? "Connected" : "Not configured"}
</span>
</div>
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>NEXTCLOUD URL</label>
<input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} />
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6, marginTop: 12 }}>USERNAME</label>
<input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} style={inputStyle} />
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6, marginTop: 12 }}>PASSWORD / APP PASSWORD</label>
<input
type="password"
value={ncPassword}
onChange={(e) => setNcPassword(e.target.value)}
placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""}
style={inputStyle}
/>
<p style={{ color: "#38496A", fontSize: 11, margin: "4px 0 0" }}>
Use an app password from Nextcloud Settings Security for better security.
</p>
</section>
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
{saved && <p style={{ color: "#38C9A8", fontSize: 13, marginBottom: 12 }}>Settings saved.</p>}
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "10px 24px", fontWeight: 600, fontSize: 14 }}
>
{saveMutation.isPending ? "Saving…" : "Save Settings"}
</button>
<button
onClick={onBack}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "10px 18px", fontSize: 14 }}
>
Cancel
</button>
</div>
</>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
return (
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}>
<div style={{ maxWidth: 540, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32 }}>
<button
onClick={() => navigate("/")}
style={{ background: "none", border: "none", color: "#5A6480", cursor: "pointer", fontSize: 13, padding: 0 }}
>
All Bands
</button>
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: 0, fontSize: 20 }}>Settings</h1>
</div>
{isLoading && <p style={{ color: "#5A6480" }}>Loading...</p>}
{me && <SettingsForm me={me} onBack={() => navigate("/")} />}
</div>
</div>
);
}
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
background: "#131720",
border: "1px solid #1C2235",
borderRadius: 6,
color: "#E2E6F0",
fontSize: 14,
boxSizing: "border-box",
};