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:
105
web/src/pages/InvitePage.tsx
Normal file
105
web/src/pages/InvitePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user