diff --git a/api/src/rehearsalhub/config.py b/api/src/rehearsalhub/config.py index 9ff9b26..6f9ae32 100644 --- a/api/src/rehearsalhub/config.py +++ b/api/src/rehearsalhub/config.py @@ -7,7 +7,7 @@ class Settings(BaseSettings): # Security secret_key: str - internal_secret: str # Shared secret for internal service-to-service calls + internal_secret: str = "dev-change-me-in-production" # Shared secret for internal service-to-service calls jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 # 1 hour diff --git a/api/src/rehearsalhub/routers/versions.py b/api/src/rehearsalhub/routers/versions.py index 9e9e4a5..f5ead69 100644 --- a/api/src/rehearsalhub/routers/versions.py +++ b/api/src/rehearsalhub/routers/versions.py @@ -79,12 +79,18 @@ async def _member_from_request( token: str | None = Query(None), session: AsyncSession = Depends(get_session), ) -> Member: - """Resolve member from Authorization header or ?token= query param.""" + """Resolve member from Authorization header, ?token= query param, or httpOnly cookie. + + The cookie fallback allows fetch()-based callers (which send credentials:include) + to use the same endpoint as EventSource callers (which must use ?token=). + """ auth_header = request.headers.get("Authorization") if auth_header: scheme, param = get_authorization_scheme_param(auth_header) if scheme.lower() == "bearer": token = param + if not token: + token = request.cookies.get("rh_token") if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required") try: diff --git a/docker-compose.yml b/docker-compose.yml index 38149b2..8df15b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,7 @@ services: NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password} REDIS_URL: redis://redis:6379/0 SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default} + INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default} DOMAIN: ${DOMAIN:-localhost} networks: - rh_net diff --git a/web/nginx.conf b/web/nginx.conf index a563fb5..c2eadd6 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -15,7 +15,9 @@ server { # Band routes — NC scan can take several minutes on large libraries. # ^~ prevents the static-asset regex below from matching /api/ paths. - location ^~ /api/v1/bands/ { + # Note: no trailing slash — nginx redirects /api/v1/bands → /api/v1/bands/ + # with 301 if the location ends in /, which breaks POST requests. + location ^~ /api/v1/bands { proxy_pass http://api:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/web/src/App.tsx b/web/src/App.tsx index 5a17e4d..083f52a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import "./index.css"; -import { ThemeProvider, useTheme } from "./theme"; import { isLoggedIn } from "./api/client"; +import { AppShell } from "./components/AppShell"; import { LoginPage } from "./pages/LoginPage"; import { HomePage } from "./pages/HomePage"; import { BandPage } from "./pages/BandPage"; @@ -19,81 +19,63 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { return isLoggedIn() ? <>{children} : ; } -function ThemeToggle() { - const { theme, toggle } = useTheme(); +function ShellRoute({ children }: { children: React.ReactNode }) { return ( - + + {children} + ); } export default function App() { return ( - - - - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - - - - - + + + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + ); } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..6fbbe27 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,581 @@ +import { useRef, useEffect, useState } from "react"; +import { useLocation, useNavigate, matchPath } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { listBands } from "../api/bands"; +import { api } from "../api/client"; +import { logout } from "../api/auth"; +import type { MemberRead } from "../api/auth"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function getInitials(name: string): string { + return name + .split(/\s+/) + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + +// ── Icons (inline SVG) ──────────────────────────────────────────────────────── + +function IconWaveform() { + return ( + + + + + + ); +} + +function IconLibrary() { + return ( + + + + ); +} + +function IconPlay() { + return ( + + + + ); +} + +function IconSettings() { + return ( + + + + + ); +} + +function IconChevron() { + return ( + + + + ); +} + +function IconSignOut() { + return ( + + + + + ); +} + +// ── NavItem ─────────────────────────────────────────────────────────────────── + +interface NavItemProps { + icon: React.ReactNode; + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +} + +function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) { + const [hovered, setHovered] = useState(false); + + const color = active + ? "#e8a22a" + : disabled + ? "rgba(255,255,255,0.18)" + : hovered + ? "rgba(255,255,255,0.7)" + : "rgba(255,255,255,0.35)"; + + const bg = active + ? "rgba(232,162,42,0.12)" + : hovered && !disabled + ? "rgba(255,255,255,0.045)" + : "transparent"; + + return ( + + ); +} + +// ── AppShell ────────────────────────────────────────────────────────────────── + +export function AppShell({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + const location = useLocation(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands }); + const { data: me } = useQuery({ + queryKey: ["me"], + queryFn: () => api.get("/auth/me"), + }); + + // Derive active band from the current URL + const bandMatch = + matchPath("/bands/:bandId/*", location.pathname) ?? + matchPath("/bands/:bandId", location.pathname); + const activeBandId = bandMatch?.params?.bandId ?? null; + const activeBand = bands?.find((b) => b.id === activeBandId) ?? null; + + // Nav active states + const isLibrary = !!( + matchPath({ path: "/bands/:bandId", end: true }, location.pathname) || + matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) || + matchPath("/bands/:bandId/sessions/:sessionId/*", location.pathname) + ); + const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname); + const isSettings = location.pathname.startsWith("/settings"); + + // Close dropdown on outside click + useEffect(() => { + if (!dropdownOpen) return; + function handleClick(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [dropdownOpen]); + + const border = "rgba(255,255,255,0.06)"; + + return ( +
+ {/* ── Sidebar ─────────────────────────────────────────── */} + + + {/* ── Main content ─────────────────────────────────────── */} +
+ {children} +
+
+ ); +} + +function SectionLabel({ + children, + style, +}: { + children: React.ReactNode; + style?: React.CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index 9066ed7..fc2a6cc 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4,7 +4,7 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; background: var(--bg); color: var(--text); @@ -14,39 +14,23 @@ 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; +/* ── Design system (dark only — no light mode in v1) ─────────────────────── */ +:root { + --bg: #0f0f12; + --bg-subtle: rgba(255,255,255,0.025); + --bg-inset: rgba(255,255,255,0.04); + --border: rgba(255,255,255,0.08); + --border-subtle: rgba(255,255,255,0.05); + --text: #eeeef2; + --text-muted: rgba(255,255,255,0.35); + --text-subtle: rgba(255,255,255,0.22); + --accent: #e8a22a; + --accent-hover: #f0b740; + --accent-bg: rgba(232,162,42,0.1); + --accent-border: rgba(232,162,42,0.28); + --accent-fg: #0f0f12; + --teal: #4dba85; + --teal-bg: rgba(61,200,120,0.1); + --danger: #e07070; + --danger-bg: rgba(220,80,80,0.1); } diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index cc2a20b..3c0f97f 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -216,12 +216,8 @@ export function BandPage() { if (!band) return
Band not found
; return ( -
+
- - ← All Bands - - {/* Band header */}

{band.name}

diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index ab6a9ee..9d65d1b 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -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 ; } 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 ( -
-
-
-

◈ RehearsalHub

-
- - -
-
+
+ {isLoading ? ( +

Loading…

+ ) : ( + <> +

+ Create your first band +

+

+ Give your band a name to get started. You can add members and connect storage after. +

-
-

Your Bands

- -
- - {showCreate && ( -
- {error &&

{error}

} - - { - setName(e.target.value); - setSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); - }} - style={inputStyle} - /> - - setSlug(e.target.value)} - style={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }} - /> - - setNcBasePath(e.target.value)} - placeholder={`bands/${slug || "my-band"}/`} - style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }} - /> -

- Path relative to your Nextcloud root. Leave blank to use bands/{slug || "slug"}/ -

-
- - -
-
- )} - - {isLoading &&

Loading...

} - -
- {bands?.map((band) => ( - - ))} - {bands?.length === 0 && ( -

No bands yet. Create one to get started.

+ {error && ( +

{error}

)} -
-
+ + + { + setName(e.target.value); + setSlug( + e.target.value + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + ); + }} + style={inputStyle} + autoFocus + /> + + + setSlug(e.target.value)} + style={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }} + /> + + + setNcBasePath(e.target.value)} + placeholder={`bands/${slug || "my-band"}/`} + style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }} + /> +

+ Path relative to your Nextcloud root. Leave blank to use{" "} + bands/{slug || "slug"}/ +

+ + + + )}
); } diff --git a/web/src/pages/SessionPage.tsx b/web/src/pages/SessionPage.tsx index 68aab29..70c45bf 100644 --- a/web/src/pages/SessionPage.tsx +++ b/web/src/pages/SessionPage.tsx @@ -60,13 +60,13 @@ export function SessionPage() { if (!session) return
Session not found
; return ( -
+
- ← Back to Band + ← Library {/* Header */} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 5a5679e..ce95d5e 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -363,20 +363,14 @@ export function SettingsPage() { const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); return ( -
+
-
- -

Settings

-
+

+ Settings +

{isLoading &&

Loading...

} - {me && navigate("/")} />} + {me && navigate(-1)} />}
); diff --git a/web/src/pages/SongPage.tsx b/web/src/pages/SongPage.tsx index 0445c27..64e213d 100644 --- a/web/src/pages/SongPage.tsx +++ b/web/src/pages/SongPage.tsx @@ -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 ( -
+
- ← Back to Band + ← Library {/* Version selector */}