feat: app shell with sidebar + bug fixes

UI:
- Add persistent sidebar (210px) with band switcher dropdown, Library/Player/Settings nav, user avatar row, and sign-out button
- Align design system CSS vars to CLAUDE.md spec (#0f0f12 bg, #e8a22a amber accent, rgba borders/text)
- Remove light mode toggle (no light mode in v1)
- Homepage auto-redirects to first band; shows create-band form only when no bands exist
- Strip full-page wrappers from all pages (shell owns layout)
- Remove debug console.log statements from SongPage

Bug fixes:
- nginx: trailing slash on `location ^~ /api/v1/bands/` caused 301 redirect on POST, dropping the request body — removed trailing slash
- API: _member_from_request (used by nc-scan stream) only accepted Bearer token, not httpOnly cookie — add rh_token cookie fallback
- API: internal_secret config field now has a dev default so the service starts without INTERNAL_SECRET env var set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-01 09:43:47 +02:00
parent ae7bf96dc1
commit d9035acdff
11 changed files with 763 additions and 255 deletions

View File

@@ -216,12 +216,8 @@ export function BandPage() {
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
return (
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
<div style={{ padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<Link to="/" style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
All Bands
</Link>
{/* Band header */}
<div style={{ marginBottom: 24 }}>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>

View File

@@ -1,13 +1,11 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, Navigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands";
import { logout } from "../api/auth";
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("");
@@ -22,131 +20,107 @@ export function HomePage() {
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() {
logout();
// Redirect to the first band once bands are loaded
if (!isLoading && bands && bands.length > 0) {
return <Navigate to={`/bands/${bands[0].id}`} replace />;
}
const inputStyle: React.CSSProperties = {
width: "100%", padding: "8px 12px",
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",
borderRadius: 6,
color: "var(--text)",
marginBottom: 12,
fontSize: 14,
};
const labelStyle: React.CSSProperties = {
display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6,
display: "block",
color: "var(--text-muted)",
fontSize: 11,
marginBottom: 6,
};
return (
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: 0 }}> RehearsalHub</h1>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => navigate("/settings")}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Settings
</button>
<button
onClick={handleSignOut}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Sign Out
</button>
</div>
</div>
<div style={{ padding: 32, maxWidth: 480, margin: "0 auto" }}>
{isLoading ? (
<p style={{ color: "var(--text-muted)" }}>Loading</p>
) : (
<>
<h2 style={{ color: "var(--text)", margin: "0 0 8px", fontSize: 17, fontWeight: 500 }}>
Create your first band
</h2>
<p style={{ color: "var(--text-muted)", fontSize: 12, margin: "0 0 24px", lineHeight: 1.6 }}>
Give your band a name to get started. You can add members and connect storage after.
</p>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Your Bands</h2>
<button
onClick={() => setShowCreate(!showCreate)}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
+ New Band
</button>
</div>
{showCreate && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 20, marginBottom: 20 }}>
{error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={labelStyle}>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={inputStyle}
/>
<label style={labelStyle}>SLUG</label>
<input
value={slug}
onChange={(e) => setSlug(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }}
/>
<label style={labelStyle}>
NEXTCLOUD BASE FOLDER <span style={{ color: "var(--text-subtle)" }}>(optional)</span>
</label>
<input
value={ncBasePath}
onChange={(e) => setNcBasePath(e.target.value)}
placeholder={`bands/${slug || "my-band"}/`}
style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }}
/>
<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: "var(--text-muted)" }}>bands/{slug || "slug"}/</code>
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => createMutation.mutate()}
disabled={!name || !slug}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
>
Create
</button>
<button
onClick={() => { setShowCreate(false); setError(null); setNcBasePath(""); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
>
Cancel
</button>
</div>
</div>
)}
{isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
<div style={{ display: "grid", gap: 8 }}>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => navigate(`/bands/${band.id}`)}
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={{ fontSize: 11, color: "var(--text-muted)", 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: "var(--teal-bg)", color: "var(--teal)", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
</div>
)}
</button>
))}
{bands?.length === 0 && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No bands yet. Create one to get started.</p>
{error && (
<p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>
)}
</div>
</div>
<label style={labelStyle}>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={inputStyle}
autoFocus
/>
<label style={labelStyle}>SLUG</label>
<input
value={slug}
onChange={(e) => setSlug(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }}
/>
<label style={labelStyle}>
NEXTCLOUD BASE FOLDER{" "}
<span style={{ color: "var(--text-subtle)" }}>(optional)</span>
</label>
<input
value={ncBasePath}
onChange={(e) => setNcBasePath(e.target.value)}
placeholder={`bands/${slug || "my-band"}/`}
style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }}
/>
<p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "0 0 20px" }}>
Path relative to your Nextcloud root. Leave blank to use{" "}
<code style={{ color: "var(--text-muted)" }}>bands/{slug || "slug"}/</code>
</p>
<button
onClick={() => createMutation.mutate()}
disabled={!name || !slug || createMutation.isPending}
style={{
background: "var(--accent)",
border: "none",
borderRadius: 6,
color: "var(--accent-fg)",
cursor: "pointer",
padding: "9px 22px",
fontWeight: 600,
fontSize: 13,
}}
>
{createMutation.isPending ? "Creating…" : "Create Band"}
</button>
</>
)}
</div>
);
}

View File

@@ -60,13 +60,13 @@ export function SessionPage() {
if (!session) return <div style={{ color: "var(--danger)", padding: 32 }}>Session not found</div>;
return (
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
<div style={{ padding: 32 }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<Link
to={`/bands/${bandId}`}
style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}
>
Back to Band
Library
</Link>
{/* Header */}

View File

@@ -363,20 +363,14 @@ export function SettingsPage() {
const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
return (
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
<div style={{ 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: "var(--text-muted)", cursor: "pointer", fontSize: 13, padding: 0 }}
>
All Bands
</button>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: 0, fontSize: 20 }}>Settings</h1>
</div>
<h1 style={{ color: "var(--text)", margin: "0 0 24px", fontSize: 17, fontWeight: 500 }}>
Settings
</h1>
{isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
{me && <SettingsForm me={me} onBack={() => navigate("/")} />}
{me && <SettingsForm me={me} onBack={() => navigate(-1)} />}
</div>
</div>
);

View File

@@ -81,38 +81,27 @@ export function SongPage() {
useEffect(() => {
if (comments) {
console.log('Comments data:', comments);
clearMarkers();
comments.forEach((comment) => {
console.log('Processing comment:', comment.id, 'timestamp:', comment.timestamp, 'avatar:', comment.author_avatar_url);
if (comment.timestamp !== undefined && comment.timestamp !== null) {
console.log('Adding marker at time:', comment.timestamp);
addMarker({
id: comment.id,
time: comment.timestamp,
onClick: () => scrollToComment(comment.id),
icon: comment.author_avatar_url || "https://via.placeholder.com/20",
icon: comment.author_avatar_url || undefined,
});
} else {
console.log('Skipping comment without timestamp:', comment.id);
}
});
}
}, [comments, addMarker, clearMarkers]);
const addCommentMutation = useMutation({
mutationFn: ({ body, timestamp }: { body: string; timestamp: number }) => {
console.log('Creating comment with timestamp:', timestamp);
return api.post(`/songs/${songId}/comments`, { body, timestamp });
},
mutationFn: ({ body, timestamp }: { body: string; timestamp: number }) =>
api.post(`/songs/${songId}/comments`, { body, timestamp }),
onSuccess: () => {
console.log('Comment created successfully');
qc.invalidateQueries({ queryKey: ["comments", songId] });
setCommentBody("");
},
onError: (error) => {
console.error('Error creating comment:', error);
}
});
const deleteCommentMutation = useMutation({
@@ -133,9 +122,9 @@ export function SongPage() {
});
return (
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
<div style={{ padding: 24 }}>
<Link to={`/bands/${bandId}`} style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 16 }}>
Back to Band
Library
</Link>
{/* Version selector */}