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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
52
web/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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
30
web/src/theme.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user