feat: band NC folder config, fix watcher event filter, add light/dark theme

- Add PATCH /bands/{id} endpoint for admins to update nc_folder_path
- Add band NC scan folder UI panel with inline edit
- Fix watcher: use activity type field (not human-readable subject) for upload detection
- Reorder watcher filters: audio extension check first, then band path, then type
- Add dark/light theme toggle using GitHub Primer-inspired CSS custom properties
- All inline styles migrated to CSS variables for theme-awareness

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-29 00:29:58 +01:00
parent 5536bf4394
commit fbac62a0ea
13 changed files with 419 additions and 211 deletions

View File

@@ -6,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member from rehearsalhub.dependencies import get_current_member
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.services.band import BandService from rehearsalhub.services.band import BandService
router = APIRouter(prefix="/bands", tags=["bands"]) router = APIRouter(prefix="/bands", tags=["bands"])
@@ -17,7 +18,6 @@ async def list_bands(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member), current_member: Member = Depends(get_current_member),
): ):
from rehearsalhub.repositories.band import BandRepository
repo = BandRepository(session) repo = BandRepository(session)
bands = await repo.list_for_member(current_member.id) bands = await repo.list_for_member(current_member.id)
return [BandRead.model_validate(b) for b in bands] return [BandRead.model_validate(b) for b in bands]
@@ -53,3 +53,29 @@ async def get_band(
if band is None: if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
return BandReadWithMembers.model_validate(band) return BandReadWithMembers.model_validate(band)
@router.patch("/{band_id}", response_model=BandRead)
async def update_band(
band_id: uuid.UUID,
data: BandUpdate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = BandRepository(session)
role = await repo.get_member_role(band_id, current_member.id)
if role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
band = await repo.get_by_id(band_id)
if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
updates: dict = {}
if data.nc_folder_path is not None:
path = data.nc_folder_path.strip()
updates["nc_folder_path"] = (path.rstrip("/") + "/") if path else None
if updates:
band = await repo.update(band, **updates)
return BandRead.model_validate(band)

View File

@@ -21,6 +21,10 @@ class BandCreate(BaseModel):
nc_base_path: str | None = None # e.g. "Bands/MyBand/" — defaults to "bands/{slug}/" nc_base_path: str | None = None # e.g. "Bands/MyBand/" — defaults to "bands/{slug}/"
class BandUpdate(BaseModel):
nc_folder_path: str | None = None # update the Nextcloud base folder for scans
class BandRead(BaseModel): class BandRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID

View File

@@ -109,27 +109,25 @@ async def poll_once(nc_client: NextcloudWatcherClient, settings: WatcherSettings
for activity in activities: for activity in activities:
activity_id = int(activity.get("activity_id", 0)) activity_id = int(activity.get("activity_id", 0))
subject = activity.get("subject", "") activity_type = activity.get("type", "")
subject = activity.get("subject", "") # human-readable, for logging only
raw_path = extract_nc_file_path(activity) raw_path = extract_nc_file_path(activity)
log.debug( log.debug(
"Activity id=%d subject=%r path=%r", "Activity id=%d type=%r subject=%r path=%r",
activity_id, subject, raw_path, activity_id, activity_type, subject, raw_path,
) )
# Advance the cursor regardless of whether we act on this event # Advance the cursor regardless of whether we act on this event
_last_activity_id = max(_last_activity_id, activity_id) _last_activity_id = max(_last_activity_id, activity_id)
if subject not in _UPLOAD_SUBJECTS:
log.debug("Skipping activity %d: subject %r not a file upload event", activity_id, subject)
continue
if raw_path is None: if raw_path is None:
log.debug("Skipping activity %d: no file path in payload", activity_id) log.debug("Skipping activity %d: no file path in payload", activity_id)
continue continue
nc_path = normalize_nc_path(raw_path, nc_client.username) nc_path = normalize_nc_path(raw_path, nc_client.username)
# Only care about audio files — skip everything else immediately
if not is_audio_file(nc_path, settings.audio_extensions): if not is_audio_file(nc_path, settings.audio_extensions):
log.debug( log.debug(
"Skipping activity %d: '%s' is not an audio file (ext: %s)", "Skipping activity %d: '%s' is not an audio file (ext: %s)",
@@ -144,6 +142,10 @@ async def poll_once(nc_client: NextcloudWatcherClient, settings: WatcherSettings
) )
continue continue
if activity_type not in _UPLOAD_SUBJECTS:
log.debug("Skipping activity %d: type %r not a file upload event", activity_id, activity_type)
continue
log.info("Detected audio upload: %s (activity %d)", nc_path, activity_id) log.info("Detected audio upload: %s (activity %d)", nc_path, activity_id)
etag = await nc_client.get_file_etag(nc_path) etag = await nc_client.get_file_etag(nc_path)
success = await register_version_with_api(nc_path, etag, settings.api_url) success = await register_version_with_api(nc_path, etag, settings.api_url)

View File

@@ -3,7 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080A0E" /> <meta name="theme-color" content="#0d1117" />
<script>
(function() {
var t = localStorage.getItem("rh_theme") || "dark";
document.documentElement.dataset.theme = t;
})();
</script>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>RehearsalHub</title> <title>RehearsalHub</title>
</head> </head>

View File

@@ -1,5 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import "./index.css";
import { ThemeProvider, useTheme } from "./theme";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage"; import { BandPage } from "./pages/BandPage";
@@ -16,8 +18,39 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return token ? <>{children}</> : <Navigate to="/login" replace />; return token ? <>{children}</> : <Navigate to="/login" replace />;
} }
function ThemeToggle() {
const { theme, toggle } = useTheme();
return (
<button
onClick={toggle}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
style={{
position: "fixed",
bottom: 20,
right: 20,
zIndex: 9999,
background: "var(--bg-subtle)",
border: "1px solid var(--border)",
borderRadius: "50%",
width: 36,
height: 36,
cursor: "pointer",
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-muted)",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
}}
>
{theme === "dark" ? "☀" : "◑"}
</button>
);
}
export default function App() { export default function App() {
return ( return (
<ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@@ -49,7 +82,9 @@ export default function App() {
<Route path="/invite/:token" element={<InvitePage />} /> <Route path="/invite/:token" element={<InvitePage />} />
<Route path="/" element={<PrivateRoute><HomePage /></PrivateRoute>} /> <Route path="/" element={<PrivateRoute><HomePage /></PrivateRoute>} />
</Routes> </Routes>
<ThemeToggle />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
); );
} }

52
web/src/index.css Normal file
View File

@@ -0,0 +1,52 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
background: var(--bg);
color: var(--text);
}
input, textarea, button, select {
font-family: inherit;
}
/* ── Dark theme (default) — GitHub Primer slate ─────────────────────────── */
:root,
[data-theme="dark"] {
--bg: #0d1117;
--bg-subtle: #161b22;
--bg-inset: #21262d;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--text-subtle: #484f58;
--accent: #f0a840;
--accent-bg: #2a1e08;
--accent-fg: #080c10;
--teal: #38c9a8;
--teal-bg: #0a2820;
--danger: #f85149;
--danger-bg: #1a0810;
}
/* ── Light theme ─────────────────────────────────────────────────────────── */
[data-theme="light"] {
--bg: #ffffff;
--bg-subtle: #f6f8fa;
--bg-inset: #eef1f5;
--border: #d0d7de;
--text: #24292f;
--text-muted: #57606a;
--text-subtle: #afb8c1;
--accent: #f0a840;
--accent-bg: #fff8e1;
--accent-fg: #1c1007;
--teal: #0a6b56;
--teal-bg: #d1f7ef;
--danger: #cf222e;
--danger-bg: #ffebe9;
}

View File

@@ -34,6 +34,8 @@ export function BandPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null); const [scanMsg, setScanMsg] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const [editingFolder, setEditingFolder] = useState(false);
const [folderInput, setFolderInput] = useState("");
const { data: band, isLoading } = useQuery({ const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId], queryKey: ["band", bandId],
@@ -92,56 +94,104 @@ export function BandPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
}); });
// We determine "am I admin?" from GET /auth/me cross-referenced with the members list. const updateFolderMutation = useMutation({
// The simplest heuristic: the creator of the band (first admin in the list) is the current user mutationFn: (nc_folder_path: string) =>
// if they appear with role=admin. We store the current member id in the JWT subject but don't api.patch(`/bands/${bandId}`, { nc_folder_path }),
// expose it yet, so we compare by checking if the members list has exactly one admin and we onSuccess: () => {
// can tell by the invite button being available on the backend (403 vs 201). qc.invalidateQueries({ queryKey: ["band", bandId] });
// For the UI we just show the Remove button for non-admin members and let the API enforce auth. setEditingFolder(false);
},
if (isLoading) return <div style={{ color: "#5A6480", padding: 32 }}>Loading...</div>; });
if (!band) return <div style={{ color: "#E85878", padding: 32 }}>Band not found</div>;
const amAdmin = members?.some((m) => m.role === "admin") ?? false; const amAdmin = members?.some((m) => m.role === "admin") ?? false;
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
return ( return (
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 32 }}> <div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}> <div style={{ maxWidth: 720, margin: "0 auto" }}>
<Link to="/" style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}> <Link to="/" style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
All Bands All Bands
</Link> </Link>
<div style={{ marginBottom: 32 }}> {/* ── Band header ── */}
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1> <div style={{ marginBottom: 24 }}>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
{band.genre_tags.length > 0 && ( {band.genre_tags.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 8 }}> <div style={{ display: "flex", gap: 4, marginTop: 8 }}>
{band.genre_tags.map((t: string) => ( {band.genre_tags.map((t: string) => (
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span> <span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* ── Nextcloud folder ── */}
<div style={{ marginBottom: 24, background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div>
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>NEXTCLOUD SCAN FOLDER</span>
<div style={{ fontFamily: "monospace", color: "var(--teal)", fontSize: 13, marginTop: 4 }}>
{band.nc_folder_path ?? `bands/${band.slug}/`}
</div>
</div>
{amAdmin && !editingFolder && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingFolder(true); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "4px 10px", fontSize: 11 }}
>
Edit
</button>
)}
</div>
{editingFolder && (
<div style={{ marginTop: 10 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={`bands/${band.slug}/`}
style={{ width: "100%", padding: "7px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateFolderMutation.mutate(folderInput)}
disabled={updateFolderMutation.isPending}
style={{ background: "var(--teal)", border: "none", borderRadius: 6, color: "var(--bg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
Save
</button>
<button
onClick={() => setEditingFolder(false)}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* ── Members ── */} {/* ── Members ── */}
<div style={{ marginBottom: 32 }}> <div style={{ marginBottom: 32 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Members</h2> <h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
<button <button
onClick={() => inviteMutation.mutate()} onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending} disabled={inviteMutation.isPending}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#F0A840", cursor: "pointer", padding: "6px 14px", fontSize: 12 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--accent)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
> >
+ Invite + Invite
</button> </button>
</div> </div>
{inviteLink && ( {inviteLink && (
<div style={{ background: "#0E1118", border: "1px solid #F0A840", borderRadius: 6, padding: "10px 14px", marginBottom: 12 }}> <div style={{ background: "var(--accent-bg)", border: "1px solid var(--accent)", borderRadius: 6, padding: "10px 14px", marginBottom: 12 }}>
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p> <p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
<code style={{ color: "#F0A840", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code> <code style={{ color: "var(--accent)", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button <button
onClick={() => setInviteLink(null)} onClick={() => setInviteLink(null)}
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "#5A6480", cursor: "pointer", fontSize: 11, padding: 0 }} style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 11, padding: 0 }}
> >
Dismiss Dismiss
</button> </button>
@@ -152,25 +202,25 @@ export function BandPage() {
{members?.map((m) => ( {members?.map((m) => (
<div <div
key={m.id} key={m.id}
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 6, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 6, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
> >
<div> <div>
<span style={{ fontWeight: 500 }}>{m.display_name}</span> <span style={{ fontWeight: 500 }}>{m.display_name}</span>
<span style={{ color: "#5A6480", fontSize: 11, marginLeft: 10 }}>{m.email}</span> <span style={{ color: "var(--text-muted)", fontSize: 11, marginLeft: 10 }}>{m.email}</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ <span style={{
fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3, fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3,
background: m.role === "admin" ? "#2A1E08" : "#0E1118", background: m.role === "admin" ? "var(--accent-bg)" : "var(--bg-inset)",
color: m.role === "admin" ? "#F0A840" : "#5A6480", color: m.role === "admin" ? "var(--accent)" : "var(--text-muted)",
border: `1px solid ${m.role === "admin" ? "#F0A840" : "#1C2235"}`, border: `1px solid ${m.role === "admin" ? "var(--accent)" : "var(--border)"}`,
}}> }}>
{m.role} {m.role}
</span> </span>
{amAdmin && m.role !== "admin" && ( {amAdmin && m.role !== "admin" && (
<button <button
onClick={() => removeMemberMutation.mutate(m.id)} onClick={() => removeMemberMutation.mutate(m.id)}
style={{ background: "none", border: "none", color: "#E85878", cursor: "pointer", fontSize: 11, padding: 0 }} style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", fontSize: 11, padding: 0 }}
> >
Remove Remove
</button> </button>
@@ -183,18 +233,18 @@ export function BandPage() {
{/* ── Songs ── */} {/* ── Songs ── */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Songs</h2> <h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Songs</h2>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => scanMutation.mutate()} onClick={() => scanMutation.mutate()}
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#38C9A8", cursor: "pointer", padding: "6px 14px", fontSize: 12 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
> >
{scanMutation.isPending ? "Scanning…" : "⟳ Scan Nextcloud"} {scanMutation.isPending ? "Scanning…" : "⟳ Scan Nextcloud"}
</button> </button>
<button <button
onClick={() => { setShowCreate(!showCreate); setError(null); }} onClick={() => { setShowCreate(!showCreate); setError(null); }}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }} style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
> >
+ New Song + New Song
</button> </button>
@@ -202,36 +252,36 @@ export function BandPage() {
</div> </div>
{scanMsg && ( {scanMsg && (
<div style={{ background: "#0A2820", border: "1px solid #38C9A8", borderRadius: 6, color: "#38C9A8", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}> <div style={{ background: "var(--teal-bg)", border: "1px solid var(--teal)", borderRadius: 6, color: "var(--teal)", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
{scanMsg} {scanMsg}
</div> </div>
)} )}
{showCreate && ( {showCreate && (
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 16 }}> <div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 20, marginBottom: 16 }}>
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>} {error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label> <label style={{ display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label>
<input <input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }} style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
autoFocus autoFocus
/> />
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 12px" }}> <p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 12px" }}>
A folder <code style={{ color: "#38C9A8" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud. A folder <code style={{ color: "var(--teal)" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud.
</p> </p>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!title} disabled={!title}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }} style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
> >
Create Create
</button> </button>
<button <button
onClick={() => { setShowCreate(false); setError(null); }} onClick={() => { setShowCreate(false); setError(null); }}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
> >
Cancel Cancel
</button> </button>
@@ -244,18 +294,18 @@ export function BandPage() {
<Link <Link
key={song.id} key={song.id}
to={`/bands/${bandId}/songs/${song.id}`} to={`/bands/${bandId}/songs/${song.id}`}
style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: "14px 18px", textDecoration: "none", color: "#E2E6F0", display: "flex", justifyContent: "space-between", alignItems: "center" }} style={{ background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 8, padding: "14px 18px", textDecoration: "none", color: "var(--text)", display: "flex", justifyContent: "space-between", alignItems: "center" }}
> >
<span>{song.title}</span> <span>{song.title}</span>
<span style={{ color: "#5A6480", fontSize: 12 }}> <span style={{ color: "var(--text-muted)", fontSize: 12 }}>
<span style={{ background: "#0E1118", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span> <span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""} {song.version_count} version{song.version_count !== 1 ? "s" : ""}
</span> </span>
</Link> </Link>
))} ))}
{songs?.length === 0 && ( {songs?.length === 0 && (
<p style={{ color: "#5A6480", fontSize: 13 }}> <p style={{ color: "var(--text-muted)", fontSize: 13 }}>
No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "#38C9A8" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>. No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
</p> </p>
)} )}
</div> </div>

View File

@@ -33,21 +33,32 @@ export function HomePage() {
navigate("/login"); navigate("/login");
} }
const inputStyle: React.CSSProperties = {
width: "100%", padding: "8px 12px",
background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 6, color: "var(--text)",
marginBottom: 12, fontSize: 14, boxSizing: "border-box",
};
const labelStyle: React.CSSProperties = {
display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6,
};
return ( return (
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}> <div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}> <div style={{ maxWidth: 720, margin: "0 auto" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: 0 }}> RehearsalHub</h1> <h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: 0 }}> RehearsalHub</h1>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => navigate("/settings")} onClick={() => navigate("/settings")}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "6px 14px", fontSize: 12 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
> >
Settings Settings
</button> </button>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "6px 14px", fontSize: 12 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
> >
Sign Out Sign Out
</button> </button>
@@ -55,56 +66,56 @@ export function HomePage() {
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Your Bands</h2> <h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Your Bands</h2>
<button <button
onClick={() => setShowCreate(!showCreate)} onClick={() => setShowCreate(!showCreate)}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }} style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
> >
+ New Band + New Band
</button> </button>
</div> </div>
{showCreate && ( {showCreate && (
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 20 }}> <div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 20, marginBottom: 20 }}>
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>} {error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>BAND NAME</label> <label style={labelStyle}>BAND NAME</label>
<input <input
value={name} value={name}
onChange={(e) => { onChange={(e) => {
setName(e.target.value); setName(e.target.value);
setSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); 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" }} style={inputStyle}
/> />
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SLUG</label> <label style={labelStyle}>SLUG</label>
<input <input
value={slug} value={slug}
onChange={(e) => setSlug(e.target.value)} 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" }} style={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }}
/> />
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}> <label style={labelStyle}>
NEXTCLOUD BASE FOLDER <span style={{ color: "#38496A" }}>(optional)</span> NEXTCLOUD BASE FOLDER <span style={{ color: "var(--text-subtle)" }}>(optional)</span>
</label> </label>
<input <input
value={ncBasePath} value={ncBasePath}
onChange={(e) => setNcBasePath(e.target.value)} onChange={(e) => setNcBasePath(e.target.value)}
placeholder={`bands/${slug || "my-band"}/`} 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" }} style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }}
/> />
<p style={{ color: "#38496A", fontSize: 11, margin: "0 0 16px" }}> <p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "0 0 16px" }}>
Path relative to your Nextcloud root. Leave blank to use <code style={{ color: "#5A6480" }}>bands/{slug || "slug"}/</code> Path relative to your Nextcloud root. Leave blank to use <code style={{ color: "var(--text-muted)" }}>bands/{slug || "slug"}/</code>
</p> </p>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!name || !slug} disabled={!name || !slug}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }} style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
> >
Create Create
</button> </button>
<button <button
onClick={() => { setShowCreate(false); setError(null); setNcBasePath(""); }} onClick={() => { setShowCreate(false); setError(null); setNcBasePath(""); }}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
> >
Cancel Cancel
</button> </button>
@@ -112,28 +123,28 @@ export function HomePage() {
</div> </div>
)} )}
{isLoading && <p style={{ color: "#5A6480" }}>Loading...</p>} {isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
<div style={{ display: "grid", gap: 8 }}> <div style={{ display: "grid", gap: 8 }}>
{bands?.map((band) => ( {bands?.map((band) => (
<button <button
key={band.id} key={band.id}
onClick={() => navigate(`/bands/${band.id}`)} onClick={() => navigate(`/bands/${band.id}`)}
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 16, textAlign: "left", cursor: "pointer", color: "#E2E6F0" }} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, textAlign: "left", cursor: "pointer", color: "var(--text)" }}
> >
<div style={{ fontWeight: 600, marginBottom: 4 }}>{band.name}</div> <div style={{ fontWeight: 600, marginBottom: 4 }}>{band.name}</div>
<div style={{ fontSize: 11, color: "#5A6480", fontFamily: "monospace" }}>{band.slug}</div> <div style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "monospace" }}>{band.slug}</div>
{band.genre_tags.length > 0 && ( {band.genre_tags.length > 0 && (
<div style={{ marginTop: 8, display: "flex", gap: 4 }}> <div style={{ marginTop: 8, display: "flex", gap: 4 }}>
{band.genre_tags.map((t) => ( {band.genre_tags.map((t) => (
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span> <span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))} ))}
</div> </div>
)} )}
</button> </button>
))} ))}
{bands?.length === 0 && ( {bands?.length === 0 && (
<p style={{ color: "#5A6480", fontSize: 13 }}>No bands yet. Create one to get started.</p> <p style={{ color: "var(--text-muted)", fontSize: 13 }}>No bands yet. Create one to get started.</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -47,29 +47,29 @@ export function InvitePage() {
} }
return ( return (
<div style={{ background: "#080A0E", minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", color: "#E2E6F0" }}> <div style={{ background: "var(--bg)", minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text)" }}>
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 12, padding: 40, maxWidth: 420, width: "100%", textAlign: "center" }}> <div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 12, padding: 40, maxWidth: 420, width: "100%", textAlign: "center" }}>
<h1 style={{ color: "#F0A840", fontFamily: "monospace", marginBottom: 8, fontSize: 22 }}> RehearsalHub</h1> <h1 style={{ color: "var(--accent)", fontFamily: "monospace", marginBottom: 8, fontSize: 22 }}> RehearsalHub</h1>
<p style={{ color: "#5A6480", fontSize: 13, marginBottom: 28 }}>Band invite</p> <p style={{ color: "var(--text-muted)", fontSize: 13, marginBottom: 28 }}>Band invite</p>
{error && ( {error && (
<div style={{ background: "#1A0810", border: "1px solid #E85878", borderRadius: 6, padding: "12px 16px", color: "#E85878", fontSize: 13, marginBottom: 20 }}> <div style={{ background: "var(--danger-bg)", border: "1px solid var(--danger)", borderRadius: 6, padding: "12px 16px", color: "var(--danger)", fontSize: 13, marginBottom: 20 }}>
{error} {error}
</div> </div>
)} )}
{done && ( {done && (
<div style={{ color: "#38C9A8", fontSize: 14 }}> <div style={{ color: "var(--teal)", fontSize: 14 }}>
Joined! Redirecting Joined! Redirecting
</div> </div>
)} )}
{!done && invite && ( {!done && invite && (
<> <>
<p style={{ color: "#E2E6F0", fontSize: 15, marginBottom: 6 }}> <p style={{ color: "var(--text)", fontSize: 15, marginBottom: 6 }}>
You've been invited to join a band as <strong style={{ color: "#F0A840" }}>{invite.role}</strong>. You've been invited to join a band as <strong style={{ color: "var(--accent)" }}>{invite.role}</strong>.
</p> </p>
<p style={{ color: "#5A6480", fontSize: 12, marginBottom: 28 }}> <p style={{ color: "var(--text-muted)", fontSize: 12, marginBottom: 28 }}>
Expires {new Date(invite.expires_at).toLocaleDateString()} Expires {new Date(invite.expires_at).toLocaleDateString()}
{invite.used_at && " · Already used"} {invite.used_at && " · Already used"}
</p> </p>
@@ -78,16 +78,16 @@ export function InvitePage() {
<button <button
onClick={accept} onClick={accept}
disabled={accepting || !!invite.used_at} 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 }} style={{ width: "100%", background: "var(--accent)", border: "none", borderRadius: 8, color: "var(--accent-fg)", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }}
> >
{accepting ? "Joining…" : "Accept Invite"} {accepting ? "Joining…" : "Accept Invite"}
</button> </button>
) : ( ) : (
<div> <div>
<p style={{ color: "#5A6480", fontSize: 13, marginBottom: 16 }}>Log in or register to accept this invite.</p> <p style={{ color: "var(--text-muted)", fontSize: 13, marginBottom: 16 }}>Log in or register to accept this invite.</p>
<button <button
onClick={goLogin} onClick={goLogin}
style={{ width: "100%", background: "#F0A840", border: "none", borderRadius: 8, color: "#080A0E", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }} style={{ width: "100%", background: "var(--accent)", border: "none", borderRadius: 8, color: "var(--accent-fg)", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }}
> >
Log in / Register Log in / Register
</button> </button>
@@ -97,7 +97,7 @@ export function InvitePage() {
)} )}
{!done && !invite && !error && ( {!done && !invite && !error && (
<p style={{ color: "#5A6480" }}>Loading invite</p> <p style={{ color: "var(--text-muted)" }}>Loading invite</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -28,43 +28,36 @@ export function LoginPage() {
} }
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: "100%", padding: "10px 12px", background: "#131720", width: "100%", padding: "10px 12px",
border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text)",
marginBottom: 16, fontSize: 14, boxSizing: "border-box", marginBottom: 16, fontSize: 14, boxSizing: "border-box",
}; };
const labelStyle: React.CSSProperties = { display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }; const labelStyle: React.CSSProperties = {
display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6,
};
return ( return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#080A0E" }}> <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "var(--bg)" }}>
<form onSubmit={handleSubmit} style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 12, padding: 32, width: 340 }}> <form onSubmit={handleSubmit} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 12, padding: 32, width: 340 }}>
<h1 style={{ color: "#F0A840", fontFamily: "monospace", marginBottom: 8 }}> RehearsalHub</h1> <h1 style={{ color: "var(--accent)", fontFamily: "monospace", marginBottom: 8 }}> RehearsalHub</h1>
<p style={{ color: "#5A6480", fontSize: 12, marginBottom: 24 }}> <p style={{ color: "var(--text-muted)", fontSize: 12, marginBottom: 24 }}>
{mode === "login" ? "Sign in to your account" : "Create a new account"} {mode === "login" ? "Sign in to your account" : "Create a new account"}
</p> </p>
{error && <p style={{ color: "#E85878", marginBottom: 16, fontSize: 13 }}>{error}</p>} {error && <p style={{ color: "var(--danger)", marginBottom: 16, fontSize: 13 }}>{error}</p>}
{mode === "register" && ( {mode === "register" && (
<> <>
<label style={labelStyle}>DISPLAY NAME</label> <label style={labelStyle}>DISPLAY NAME</label>
<input <input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} required style={inputStyle} />
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
style={inputStyle}
/>
</> </>
)} )}
<label style={labelStyle}>EMAIL</label> <label style={labelStyle}>EMAIL</label>
<input <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required style={inputStyle} />
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={inputStyle}
/>
<label style={labelStyle}>PASSWORD</label> <label style={labelStyle}>PASSWORD</label>
<input <input
@@ -75,16 +68,16 @@ export function LoginPage() {
style={{ ...inputStyle, marginBottom: 24 }} style={{ ...inputStyle, marginBottom: 24 }}
/> />
<button type="submit" style={{ width: "100%", padding: "12px", background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", fontWeight: 600, cursor: "pointer", fontSize: 14 }}> <button type="submit" style={{ width: "100%", padding: "12px", background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", fontWeight: 600, cursor: "pointer", fontSize: 14 }}>
{mode === "login" ? "Sign In" : "Create Account"} {mode === "login" ? "Sign In" : "Create Account"}
</button> </button>
<p style={{ textAlign: "center", marginTop: 16, fontSize: 12, color: "#5A6480" }}> <p style={{ textAlign: "center", marginTop: 16, fontSize: 12, color: "var(--text-muted)" }}>
{mode === "login" ? "Don't have an account? " : "Already have an account? "} {mode === "login" ? "Don't have an account? " : "Already have an account? "}
<button <button
type="button" type="button"
onClick={() => { setMode(mode === "login" ? "register" : "login"); setError(null); }} onClick={() => { setMode(mode === "login" ? "register" : "login"); setError(null); }}
style={{ background: "none", border: "none", color: "#F0A840", cursor: "pointer", fontSize: 12, padding: 0 }} style={{ background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: 12, padding: 0 }}
> >
{mode === "login" ? "Register" : "Sign In"} {mode === "login" ? "Register" : "Sign In"}
</button> </button>

View File

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

View File

@@ -74,8 +74,8 @@ export function SongPage() {
}); });
return ( return (
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}> <div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
<Link to={`/bands/${bandId}`} style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 16 }}> <Link to={`/bands/${bandId}`} style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 16 }}>
Back to Band Back to Band
</Link> </Link>
@@ -86,9 +86,10 @@ export function SongPage() {
key={v.id} key={v.id}
onClick={() => setSelectedVersionId(v.id)} onClick={() => setSelectedVersionId(v.id)}
style={{ style={{
background: v.id === activeVersion ? "#2A1E08" : "#131720", background: v.id === activeVersion ? "var(--accent-bg)" : "var(--bg-inset)",
border: `1px solid ${v.id === activeVersion ? "#F0A840" : "#1C2235"}`, border: `1px solid ${v.id === activeVersion ? "var(--accent)" : "var(--border)"}`,
borderRadius: 6, padding: "6px 14px", color: v.id === activeVersion ? "#F0A840" : "#5A6480", borderRadius: 6, padding: "6px 14px",
color: v.id === activeVersion ? "var(--accent)" : "var(--text-muted)",
cursor: "pointer", fontSize: 12, fontFamily: "monospace", cursor: "pointer", fontSize: 12, fontFamily: "monospace",
}} }}
> >
@@ -98,21 +99,16 @@ export function SongPage() {
</div> </div>
{/* Waveform */} {/* Waveform */}
<div <div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "16px 16px 8px", marginBottom: 16 }}>
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: "16px 16px 8px", marginBottom: 16 }}
onClick={(_e) => {
// TODO: seek on click (needs duration from wavesurfer)
}}
>
<div ref={waveformRef} /> <div ref={waveformRef} />
<div style={{ display: "flex", gap: 12, marginTop: 8 }}> <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
<button <button
onClick={isPlaying ? pause : play} onClick={isPlaying ? pause : play}
style={{ background: "#F0A840", border: "none", borderRadius: 6, padding: "6px 18px", cursor: "pointer", fontWeight: 600, color: "#080A0E" }} style={{ background: "var(--accent)", border: "none", borderRadius: 6, padding: "6px 18px", cursor: "pointer", fontWeight: 600, color: "var(--accent-fg)" }}
> >
{isPlaying ? "⏸ Pause" : "▶ Play"} {isPlaying ? "⏸ Pause" : "▶ Play"}
</button> </button>
<span style={{ color: "#5A6480", fontSize: 12, alignSelf: "center" }}> <span style={{ color: "var(--text-muted)", fontSize: 12, alignSelf: "center" }}>
{formatTime(currentTime)} {formatTime(currentTime)}
</span> </span>
</div> </div>
@@ -127,28 +123,28 @@ export function SongPage() {
{/* Comments */} {/* Comments */}
<div> <div>
<h2 style={{ fontSize: 14, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 14 }}>COMMENTS</h2> <h2 style={{ fontSize: 14, color: "var(--text-muted)", fontFamily: "monospace", letterSpacing: 1, marginBottom: 14 }}>COMMENTS</h2>
<div style={{ display: "grid", gap: 8, marginBottom: 16 }}> <div style={{ display: "grid", gap: 8, marginBottom: 16 }}>
{comments?.map((c) => ( {comments?.map((c) => (
<div key={c.id} style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: "12px 16px" }}> <div key={c.id} style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}> <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: 13, color: "#E2E6F0" }}>{c.author_name}</span> <span style={{ fontWeight: 600, fontSize: 13, color: "var(--text)" }}>{c.author_name}</span>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ color: "#38496A", fontSize: 11 }}>{new Date(c.created_at).toLocaleString()}</span> <span style={{ color: "var(--text-subtle)", fontSize: 11 }}>{new Date(c.created_at).toLocaleString()}</span>
<button <button
onClick={() => deleteCommentMutation.mutate(c.id)} onClick={() => deleteCommentMutation.mutate(c.id)}
style={{ background: "none", border: "none", color: "#38496A", cursor: "pointer", fontSize: 11, padding: 0 }} style={{ background: "none", border: "none", color: "var(--text-subtle)", cursor: "pointer", fontSize: 11, padding: 0 }}
> >
Delete Delete
</button> </button>
</div> </div>
</div> </div>
<p style={{ margin: 0, fontSize: 13, color: "#C8CDD8", lineHeight: 1.5 }}>{c.body}</p> <p style={{ margin: 0, fontSize: 13, color: "var(--text)", lineHeight: 1.5 }}>{c.body}</p>
</div> </div>
))} ))}
{comments?.length === 0 && ( {comments?.length === 0 && (
<p style={{ color: "#38496A", fontSize: 13 }}>No comments yet. Be the first.</p> <p style={{ color: "var(--text-subtle)", fontSize: 13 }}>No comments yet. Be the first.</p>
)} )}
</div> </div>
@@ -158,12 +154,12 @@ export function SongPage() {
onChange={(e) => setCommentBody(e.target.value)} onChange={(e) => setCommentBody(e.target.value)}
placeholder="Add a comment…" placeholder="Add a comment…"
rows={2} rows={2}
style={{ flex: 1, padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", fontSize: 13, resize: "vertical", fontFamily: "inherit" }} style={{ flex: 1, padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 13, resize: "vertical", fontFamily: "inherit" }}
/> />
<button <button
onClick={() => commentBody.trim() && addCommentMutation.mutate(commentBody.trim())} onClick={() => commentBody.trim() && addCommentMutation.mutate(commentBody.trim())}
disabled={!commentBody.trim() || addCommentMutation.isPending} disabled={!commentBody.trim() || addCommentMutation.isPending}
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "0 18px", fontWeight: 600, fontSize: 13, alignSelf: "stretch" }} style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "0 18px", fontWeight: 600, fontSize: 13, alignSelf: "stretch" }}
> >
Post Post
</button> </button>
@@ -181,24 +177,24 @@ function AnnotationCard({ annotation: a, onSeek, versionId }: { annotation: Anno
}); });
return ( return (
<div style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: 14 }}> <div style={{ background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 8, padding: 14 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 6 }}> <div style={{ display: "flex", gap: 8, marginBottom: 6 }}>
<button <button
onClick={() => onSeek(a.timestamp_ms / 1000)} onClick={() => onSeek(a.timestamp_ms / 1000)}
style={{ background: "#2A1E08", border: "1px solid #F0A840", borderRadius: 4, color: "#F0A840", cursor: "pointer", fontSize: 10, padding: "2px 8px", fontFamily: "monospace" }} style={{ background: "var(--accent-bg)", border: "1px solid var(--accent)", borderRadius: 4, color: "var(--accent)", cursor: "pointer", fontSize: 10, padding: "2px 8px", fontFamily: "monospace" }}
> >
{formatTime(a.timestamp_ms / 1000)} {formatTime(a.timestamp_ms / 1000)}
{a.range_end_ms != null && `${formatTime(a.range_end_ms / 1000)}`} {a.range_end_ms != null && `${formatTime(a.range_end_ms / 1000)}`}
</button> </button>
<span style={{ color: "#5A6480", fontSize: 11 }}>{a.type}</span> <span style={{ color: "var(--text-muted)", fontSize: 11 }}>{a.type}</span>
{a.label && <span style={{ color: "#38C9A8", fontSize: 11 }}>{a.label}</span>} {a.label && <span style={{ color: "var(--teal)", fontSize: 11 }}>{a.label}</span>}
{a.tags.map((t) => ( {a.tags.map((t) => (
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span> <span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))} ))}
</div> </div>
{a.body && <p style={{ color: "#E2E6F0", margin: 0, fontSize: 13 }}>{a.body}</p>} {a.body && <p style={{ color: "var(--text)", margin: 0, fontSize: 13 }}>{a.body}</p>}
{a.range_analysis && ( {a.range_analysis && (
<div style={{ marginTop: 8, display: "flex", gap: 12, fontSize: 11, color: "#5A6480" }}> <div style={{ marginTop: 8, display: "flex", gap: 12, fontSize: 11, color: "var(--text-muted)" }}>
{a.range_analysis.bpm && <span> {a.range_analysis.bpm.toFixed(1)} BPM</span>} {a.range_analysis.bpm && <span> {a.range_analysis.bpm.toFixed(1)} BPM</span>}
{a.range_analysis.key && <span>🎵 {a.range_analysis.key}</span>} {a.range_analysis.key && <span>🎵 {a.range_analysis.key}</span>}
{a.range_analysis.avg_loudness_lufs && <span>{a.range_analysis.avg_loudness_lufs.toFixed(1)} LUFS</span>} {a.range_analysis.avg_loudness_lufs && <span>{a.range_analysis.avg_loudness_lufs.toFixed(1)} LUFS</span>}
@@ -209,10 +205,10 @@ function AnnotationCard({ annotation: a, onSeek, versionId }: { annotation: Anno
<button <button
key={emoji} key={emoji}
onClick={() => reactionMutation.mutate(emoji)} onClick={() => reactionMutation.mutate(emoji)}
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 4, cursor: "pointer", padding: "2px 6px", fontSize: 14 }} style={{ background: "none", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", padding: "2px 6px", fontSize: 14 }}
> >
{emoji}{" "} {emoji}{" "}
<span style={{ fontSize: 10, color: "#5A6480" }}> <span style={{ fontSize: 10, color: "var(--text-muted)" }}>
{a.reactions.filter((r) => r.emoji === emoji).length || ""} {a.reactions.filter((r) => r.emoji === emoji).length || ""}
</span> </span>
</button> </button>

30
web/src/theme.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
import { createElement } from "react";
type Theme = "dark" | "light";
interface ThemeCtx {
theme: Theme;
toggle: () => void;
}
const ThemeContext = createContext<ThemeCtx>({ theme: "dark", toggle: () => {} });
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem("rh_theme") as Theme) ?? "dark";
});
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem("rh_theme", theme);
}, [theme]);
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return createElement(ThemeContext.Provider, { value: { theme, toggle } }, children);
}
export function useTheme(): ThemeCtx {
return useContext(ThemeContext);
}