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,67 @@
import { useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
export interface UseWaveformOptions {
url: string | null;
peaksUrl: string | null;
onReady?: (duration: number) => void;
onTimeUpdate?: (currentTime: number) => void;
}
export function useWaveform(
containerRef: React.RefObject<HTMLDivElement>,
options: UseWaveformOptions
) {
const wsRef = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
useEffect(() => {
if (!containerRef.current || !options.url) return;
const ws = WaveSurfer.create({
container: containerRef.current,
waveColor: "#2A3050",
progressColor: "#F0A840",
cursorColor: "#FFD080",
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
});
ws.load(options.url);
ws.on("ready", () => {
setIsReady(true);
options.onReady?.(ws.getDuration());
});
ws.on("audioprocess", (time) => {
setCurrentTime(time);
options.onTimeUpdate?.(time);
});
ws.on("play", () => setIsPlaying(true));
ws.on("pause", () => setIsPlaying(false));
ws.on("finish", () => setIsPlaying(false));
wsRef.current = ws;
return () => {
ws.destroy();
wsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url]);
const play = () => wsRef.current?.play();
const pause = () => wsRef.current?.pause();
const seekTo = (time: number) => {
if (wsRef.current && isReady) {
wsRef.current.setTime(time);
}
};
return { isPlaying, isReady, currentTime, play, pause, seekTo };
}