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:
142
web/src/pages/HomePage.tsx
Normal file
142
web/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { listBands, createBand } from "../api/bands";
|
||||
import { clearToken } from "../api/client";
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [ncBasePath, setNcBasePath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: bands, isLoading } = useQuery({
|
||||
queryKey: ["bands"],
|
||||
queryFn: listBands,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createBand({ name, slug, ...(ncBasePath ? { nc_base_path: ncBasePath } : {}) }),
|
||||
onSuccess: (band) => {
|
||||
qc.invalidateQueries({ queryKey: ["bands"] });
|
||||
setName(""); setSlug(""); setNcBasePath("");
|
||||
navigate(`/bands/${band.id}`);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
|
||||
});
|
||||
|
||||
function handleSignOut() {
|
||||
clearToken();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}>
|
||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: 0 }}>◈ RehearsalHub</h1>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Your Bands</h2>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
+ New Band
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 20 }}>
|
||||
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>BAND NAME</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
|
||||
}}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
|
||||
/>
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SLUG</label>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 16, fontSize: 14, fontFamily: "monospace", boxSizing: "border-box" }}
|
||||
/>
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>
|
||||
NEXTCLOUD BASE FOLDER <span style={{ color: "#38496A" }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={ncBasePath}
|
||||
onChange={(e) => setNcBasePath(e.target.value)}
|
||||
placeholder={`bands/${slug || "my-band"}/`}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 4, fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
|
||||
/>
|
||||
<p style={{ color: "#38496A", fontSize: 11, margin: "0 0 16px" }}>
|
||||
Path relative to your Nextcloud root. Leave blank to use <code style={{ color: "#5A6480" }}>bands/{slug || "slug"}/</code>
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!name || !slug}
|
||||
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); setNcBasePath(""); }}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p style={{ color: "#5A6480" }}>Loading...</p>}
|
||||
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{bands?.map((band) => (
|
||||
<button
|
||||
key={band.id}
|
||||
onClick={() => navigate(`/bands/${band.id}`)}
|
||||
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 16, textAlign: "left", cursor: "pointer", color: "#E2E6F0" }}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{band.name}</div>
|
||||
<div style={{ fontSize: 11, color: "#5A6480", fontFamily: "monospace" }}>{band.slug}</div>
|
||||
{band.genre_tags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 4 }}>
|
||||
{band.genre_tags.map((t) => (
|
||||
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{bands?.length === 0 && (
|
||||
<p style={{ color: "#5A6480", fontSize: 13 }}>No bands yet. Create one to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user