Merge branch 'feature/ui-ux-improvements'
Adds persistent sidebar shell and fixes nginx/auth bugs.
This commit is contained in:
@@ -7,7 +7,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Security
|
# Security
|
||||||
secret_key: str
|
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"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 # 1 hour
|
access_token_expire_minutes: int = 60 # 1 hour
|
||||||
|
|
||||||
|
|||||||
@@ -79,12 +79,18 @@ async def _member_from_request(
|
|||||||
token: str | None = Query(None),
|
token: str | None = Query(None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Member:
|
) -> 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")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header:
|
if auth_header:
|
||||||
scheme, param = get_authorization_scheme_param(auth_header)
|
scheme, param = get_authorization_scheme_param(auth_header)
|
||||||
if scheme.lower() == "bearer":
|
if scheme.lower() == "bearer":
|
||||||
token = param
|
token = param
|
||||||
|
if not token:
|
||||||
|
token = request.cookies.get("rh_token")
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ services:
|
|||||||
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
|
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
|
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}
|
DOMAIN: ${DOMAIN:-localhost}
|
||||||
networks:
|
networks:
|
||||||
- rh_net
|
- rh_net
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ server {
|
|||||||
|
|
||||||
# Band routes — NC scan can take several minutes on large libraries.
|
# Band routes — NC scan can take several minutes on large libraries.
|
||||||
# ^~ prevents the static-asset regex below from matching /api/ paths.
|
# ^~ 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_pass http://api:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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 "./index.css";
|
||||||
import { ThemeProvider, useTheme } from "./theme";
|
|
||||||
import { isLoggedIn } from "./api/client";
|
import { isLoggedIn } from "./api/client";
|
||||||
|
import { AppShell } from "./components/AppShell";
|
||||||
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";
|
||||||
@@ -19,81 +19,63 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return isLoggedIn() ? <>{children}</> : <Navigate to="/login" replace />;
|
return isLoggedIn() ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeToggle() {
|
function ShellRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { theme, toggle } = useTheme();
|
|
||||||
return (
|
return (
|
||||||
<button
|
<PrivateRoute>
|
||||||
onClick={toggle}
|
<AppShell>{children}</AppShell>
|
||||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
</PrivateRoute>
|
||||||
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>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/invite/:token" element={<InvitePage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ShellRoute>
|
||||||
|
<HomePage />
|
||||||
|
</ShellRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bands/:bandId"
|
path="/bands/:bandId"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<ShellRoute>
|
||||||
<BandPage />
|
<BandPage />
|
||||||
</PrivateRoute>
|
</ShellRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bands/:bandId/sessions/:sessionId"
|
path="/bands/:bandId/sessions/:sessionId"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<ShellRoute>
|
||||||
<SessionPage />
|
<SessionPage />
|
||||||
</PrivateRoute>
|
</ShellRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bands/:bandId/songs/:songId"
|
path="/bands/:bandId/songs/:songId"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<ShellRoute>
|
||||||
<SongPage />
|
<SongPage />
|
||||||
</PrivateRoute>
|
</ShellRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<ShellRoute>
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
</PrivateRoute>
|
</ShellRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/invite/:token" element={<InvitePage />} />
|
|
||||||
<Route path="/" element={<PrivateRoute><HomePage /></PrivateRoute>} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
<ThemeToggle />
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
581
web/src/components/AppShell.tsx
Normal file
581
web/src/components/AppShell.tsx
Normal file
@@ -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 (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" />
|
||||||
|
<rect x="1" y="5.5" width="9" height="2" rx="1" fill="white" opacity=".7" />
|
||||||
|
<rect x="1" y="9.5" width="11" height="2" rx="1" fill="white" opacity=".8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconLibrary() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||||
|
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPlay() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||||
|
<path d="M3 2l9 5-9 5V2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSettings() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||||
|
<circle cx="7" cy="7" r="2" />
|
||||||
|
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevron() {
|
||||||
|
return (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 5l3 3 3-3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSignOut() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 2H2.5A1.5 1.5 0 0 0 1 3.5v7A1.5 1.5 0 0 0 2.5 12H5" />
|
||||||
|
<path d="M9 10l3-3-3-3M12 7H5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 9,
|
||||||
|
width: "100%",
|
||||||
|
padding: "7px 10px",
|
||||||
|
borderRadius: 7,
|
||||||
|
border: "none",
|
||||||
|
cursor: disabled ? "default" : "pointer",
|
||||||
|
color,
|
||||||
|
background: bg,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "left",
|
||||||
|
marginBottom: 1,
|
||||||
|
transition: "background 0.12s, color 0.12s",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AppShell ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
||||||
|
const { data: me } = useQuery({
|
||||||
|
queryKey: ["me"],
|
||||||
|
queryFn: () => api.get<MemberRead>("/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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: "100vh",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#0f0f12",
|
||||||
|
color: "#eeeef2",
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Sidebar ─────────────────────────────────────────── */}
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: 210,
|
||||||
|
minWidth: 210,
|
||||||
|
background: "#0b0b0e",
|
||||||
|
borderRight: `1px solid ${border}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "17px 14px 14px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
borderBottom: `1px solid ${border}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
background: "#e8a22a",
|
||||||
|
borderRadius: 7,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconWaveform />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}>
|
||||||
|
RehearsalHub
|
||||||
|
</div>
|
||||||
|
{activeBand && (
|
||||||
|
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.25)", marginTop: 1 }}>
|
||||||
|
{activeBand.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Band switcher */}
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={{
|
||||||
|
padding: "10px 8px",
|
||||||
|
borderBottom: `1px solid ${border}`,
|
||||||
|
position: "relative",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "7px 9px",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.07)",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#eeeef2",
|
||||||
|
textAlign: "left",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
background: "rgba(232,162,42,0.15)",
|
||||||
|
border: "1px solid rgba(232,162,42,0.3)",
|
||||||
|
borderRadius: 7,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#e8a22a",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeBand ? getInitials(activeBand.name) : "?"}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeBand?.name ?? "Select a band"}
|
||||||
|
</span>
|
||||||
|
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
|
||||||
|
<IconChevron />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "calc(100% - 2px)",
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
background: "#18181e",
|
||||||
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 6,
|
||||||
|
zIndex: 100,
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bands?.map((band) => (
|
||||||
|
<button
|
||||||
|
key={band.id}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/bands/${band.id}`);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "7px 9px",
|
||||||
|
marginBottom: 1,
|
||||||
|
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#eeeef2",
|
||||||
|
textAlign: "left",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 5,
|
||||||
|
background: "rgba(232,162,42,0.15)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#e8a22a",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(band.name)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "rgba(255,255,255,0.62)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{band.name}
|
||||||
|
</span>
|
||||||
|
{band.id === activeBandId && (
|
||||||
|
<span style={{ fontSize: 10, color: "#e8a22a", flexShrink: 0 }}>✓</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
marginTop: 4,
|
||||||
|
paddingTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/");
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "7px 9px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "rgba(255,255,255,0.35)",
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "left",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
|
||||||
|
Create new band
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}>
|
||||||
|
{activeBand && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>{activeBand.name}</SectionLabel>
|
||||||
|
<NavItem
|
||||||
|
icon={<IconLibrary />}
|
||||||
|
label="Library"
|
||||||
|
active={isLibrary}
|
||||||
|
onClick={() => navigate(`/bands/${activeBand.id}`)}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
icon={<IconPlay />}
|
||||||
|
label="Player"
|
||||||
|
active={isPlayer}
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={!isPlayer}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionLabel style={{ paddingTop: activeBand ? 14 : 0 }}>Account</SectionLabel>
|
||||||
|
<NavItem
|
||||||
|
icon={<IconSettings />}
|
||||||
|
label="Settings"
|
||||||
|
active={isSettings}
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
borderTop: `1px solid ${border}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/settings")}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "6px 8px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#eeeef2",
|
||||||
|
textAlign: "left",
|
||||||
|
minWidth: 0,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{me?.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={me.avatar_url}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
objectFit: "cover",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(232,162,42,0.18)",
|
||||||
|
border: "1.5px solid rgba(232,162,42,0.35)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#e8a22a",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(me?.display_name ?? "?")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "rgba(255,255,255,0.55)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{me?.display_name ?? "…"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
title="Sign out"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 7,
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "rgba(255,255,255,0.2)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "border-color 0.12s, color 0.12s",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)";
|
||||||
|
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "transparent";
|
||||||
|
e.currentTarget.style.color = "rgba(255,255,255,0.2)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconSignOut />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Main content ─────────────────────────────────────── */}
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "#0f0f12",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.2)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.7px",
|
||||||
|
padding: "0 6px 5px",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -14,39 +14,23 @@ input, textarea, button, select {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dark theme (default) — GitHub Primer slate ─────────────────────────── */
|
/* ── Design system (dark only — no light mode in v1) ─────────────────────── */
|
||||||
:root,
|
:root {
|
||||||
[data-theme="dark"] {
|
--bg: #0f0f12;
|
||||||
--bg: #0d1117;
|
--bg-subtle: rgba(255,255,255,0.025);
|
||||||
--bg-subtle: #161b22;
|
--bg-inset: rgba(255,255,255,0.04);
|
||||||
--bg-inset: #21262d;
|
--border: rgba(255,255,255,0.08);
|
||||||
--border: #30363d;
|
--border-subtle: rgba(255,255,255,0.05);
|
||||||
--text: #e6edf3;
|
--text: #eeeef2;
|
||||||
--text-muted: #8b949e;
|
--text-muted: rgba(255,255,255,0.35);
|
||||||
--text-subtle: #484f58;
|
--text-subtle: rgba(255,255,255,0.22);
|
||||||
--accent: #f0a840;
|
--accent: #e8a22a;
|
||||||
--accent-bg: #2a1e08;
|
--accent-hover: #f0b740;
|
||||||
--accent-fg: #080c10;
|
--accent-bg: rgba(232,162,42,0.1);
|
||||||
--teal: #38c9a8;
|
--accent-border: rgba(232,162,42,0.28);
|
||||||
--teal-bg: #0a2820;
|
--accent-fg: #0f0f12;
|
||||||
--danger: #f85149;
|
--teal: #4dba85;
|
||||||
--danger-bg: #1a0810;
|
--teal-bg: rgba(61,200,120,0.1);
|
||||||
}
|
--danger: #e07070;
|
||||||
|
--danger-bg: rgba(220,80,80,0.1);
|
||||||
/* ── 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,12 +216,8 @@ export function BandPage() {
|
|||||||
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
<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 */}
|
{/* Band header */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useState } from "react";
|
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { listBands, createBand } from "../api/bands";
|
import { listBands, createBand } from "../api/bands";
|
||||||
import { logout } from "../api/auth";
|
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [ncBasePath, setNcBasePath] = useState("");
|
const [ncBasePath, setNcBasePath] = useState("");
|
||||||
@@ -22,78 +20,77 @@ export function HomePage() {
|
|||||||
mutationFn: () => createBand({ name, slug, ...(ncBasePath ? { nc_base_path: ncBasePath } : {}) }),
|
mutationFn: () => createBand({ name, slug, ...(ncBasePath ? { nc_base_path: ncBasePath } : {}) }),
|
||||||
onSuccess: (band) => {
|
onSuccess: (band) => {
|
||||||
qc.invalidateQueries({ queryKey: ["bands"] });
|
qc.invalidateQueries({ queryKey: ["bands"] });
|
||||||
setName(""); setSlug(""); setNcBasePath("");
|
|
||||||
navigate(`/bands/${band.id}`);
|
navigate(`/bands/${band.id}`);
|
||||||
},
|
},
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
|
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSignOut() {
|
// Redirect to the first band once bands are loaded
|
||||||
logout();
|
if (!isLoading && bands && bands.length > 0) {
|
||||||
|
return <Navigate to={`/bands/${bands[0].id}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%", padding: "8px 12px",
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
background: "var(--bg-inset)",
|
background: "var(--bg-inset)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: 6, color: "var(--text)",
|
borderRadius: 6,
|
||||||
marginBottom: 12, fontSize: 14, boxSizing: "border-box",
|
color: "var(--text)",
|
||||||
|
marginBottom: 12,
|
||||||
|
fontSize: 14,
|
||||||
};
|
};
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
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 (
|
return (
|
||||||
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
|
<div style={{ padding: 32, maxWidth: 480, margin: "0 auto" }}>
|
||||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
{isLoading ? (
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
|
<p style={{ color: "var(--text-muted)" }}>Loading…</p>
|
||||||
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: 0 }}>◈ RehearsalHub</h1>
|
) : (
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<>
|
||||||
<button
|
<h2 style={{ color: "var(--text)", margin: "0 0 8px", fontSize: 17, fontWeight: 500 }}>
|
||||||
onClick={() => navigate("/settings")}
|
Create your first band
|
||||||
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
</h2>
|
||||||
>
|
<p style={{ color: "var(--text-muted)", fontSize: 12, margin: "0 0 24px", lineHeight: 1.6 }}>
|
||||||
Settings
|
Give your band a name to get started. You can add members and connect storage after.
|
||||||
</button>
|
</p>
|
||||||
<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={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
{error && (
|
||||||
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Your Bands</h2>
|
<p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>
|
||||||
<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>
|
<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={inputStyle}
|
style={inputStyle}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={labelStyle}>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={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }}
|
style={{ ...inputStyle, fontFamily: "monospace", marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
NEXTCLOUD BASE FOLDER <span style={{ color: "var(--text-subtle)" }}>(optional)</span>
|
NEXTCLOUD BASE FOLDER{" "}
|
||||||
|
<span style={{ color: "var(--text-subtle)" }}>(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={ncBasePath}
|
value={ncBasePath}
|
||||||
@@ -101,52 +98,29 @@ export function HomePage() {
|
|||||||
placeholder={`bands/${slug || "my-band"}/`}
|
placeholder={`bands/${slug || "my-band"}/`}
|
||||||
style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }}
|
style={{ ...inputStyle, fontSize: 13, fontFamily: "monospace", marginBottom: 4 }}
|
||||||
/>
|
/>
|
||||||
<p style={{ color: "var(--text-subtle)", fontSize: 11, margin: "0 0 16px" }}>
|
<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>
|
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 }}>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
disabled={!name || !slug}
|
disabled={!name || !slug || createMutation.isPending}
|
||||||
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "var(--accent-fg)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "9px 22px",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Create
|
{createMutation.isPending ? "Creating…" : "Create Band"}
|
||||||
</button>
|
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ export function SessionPage() {
|
|||||||
if (!session) return <div style={{ color: "var(--danger)", padding: 32 }}>Session not found</div>;
|
if (!session) return <div style={{ color: "var(--danger)", padding: 32 }}>Session not found</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||||
<Link
|
<Link
|
||||||
to={`/bands/${bandId}`}
|
to={`/bands/${bandId}`}
|
||||||
style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}
|
style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}
|
||||||
>
|
>
|
||||||
← Back to Band
|
← Library
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -363,20 +363,14 @@ 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: "var(--bg)", minHeight: "100vh", color: "var(--text)", padding: 24 }}>
|
<div style={{ 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 }}>
|
<h1 style={{ color: "var(--text)", margin: "0 0 24px", fontSize: 17, fontWeight: 500 }}>
|
||||||
<button
|
Settings
|
||||||
onClick={() => navigate("/")}
|
</h1>
|
||||||
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>
|
|
||||||
|
|
||||||
{isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
|
{isLoading && <p style={{ color: "var(--text-muted)" }}>Loading...</p>}
|
||||||
{me && <SettingsForm me={me} onBack={() => navigate("/")} />}
|
{me && <SettingsForm me={me} onBack={() => navigate(-1)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,38 +81,27 @@ export function SongPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (comments) {
|
if (comments) {
|
||||||
console.log('Comments data:', comments);
|
|
||||||
clearMarkers();
|
clearMarkers();
|
||||||
comments.forEach((comment) => {
|
comments.forEach((comment) => {
|
||||||
console.log('Processing comment:', comment.id, 'timestamp:', comment.timestamp, 'avatar:', comment.author_avatar_url);
|
|
||||||
if (comment.timestamp !== undefined && comment.timestamp !== null) {
|
if (comment.timestamp !== undefined && comment.timestamp !== null) {
|
||||||
console.log('Adding marker at time:', comment.timestamp);
|
|
||||||
addMarker({
|
addMarker({
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
time: comment.timestamp,
|
time: comment.timestamp,
|
||||||
onClick: () => scrollToComment(comment.id),
|
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]);
|
}, [comments, addMarker, clearMarkers]);
|
||||||
|
|
||||||
const addCommentMutation = useMutation({
|
const addCommentMutation = useMutation({
|
||||||
mutationFn: ({ body, timestamp }: { body: string; timestamp: number }) => {
|
mutationFn: ({ body, timestamp }: { body: string; timestamp: number }) =>
|
||||||
console.log('Creating comment with timestamp:', timestamp);
|
api.post(`/songs/${songId}/comments`, { body, timestamp }),
|
||||||
return api.post(`/songs/${songId}/comments`, { body, timestamp });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('Comment created successfully');
|
|
||||||
qc.invalidateQueries({ queryKey: ["comments", songId] });
|
qc.invalidateQueries({ queryKey: ["comments", songId] });
|
||||||
setCommentBody("");
|
setCommentBody("");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
console.error('Error creating comment:', error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteCommentMutation = useMutation({
|
const deleteCommentMutation = useMutation({
|
||||||
@@ -133,9 +122,9 @@ export function SongPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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 }}>
|
<Link to={`/bands/${bandId}`} style={{ color: "var(--text-muted)", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 16 }}>
|
||||||
← Back to Band
|
← Library
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Version selector */}
|
{/* Version selector */}
|
||||||
|
|||||||
Reference in New Issue
Block a user