From fbac62a0ea8301e44dfcd86a099c1fd0c20ac72f Mon Sep 17 00:00:00 2001 From: Steffen Schuhmann Date: Sun, 29 Mar 2026 00:29:58 +0100 Subject: [PATCH] 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 --- api/src/rehearsalhub/routers/bands.py | 30 +++++- api/src/rehearsalhub/schemas/band.py | 4 + watcher/src/watcher/event_loop.py | 16 +-- web/index.html | 8 +- web/src/App.tsx | 103 ++++++++++++------- web/src/index.css | 52 ++++++++++ web/src/pages/BandPage.tsx | 136 ++++++++++++++++++-------- web/src/pages/HomePage.tsx | 59 ++++++----- web/src/pages/InvitePage.tsx | 26 ++--- web/src/pages/LoginPage.tsx | 43 ++++---- web/src/pages/SettingsPage.tsx | 65 ++++++------ web/src/pages/SongPage.tsx | 58 +++++------ web/src/theme.ts | 30 ++++++ 13 files changed, 419 insertions(+), 211 deletions(-) create mode 100644 web/src/index.css create mode 100644 web/src/theme.ts diff --git a/api/src/rehearsalhub/routers/bands.py b/api/src/rehearsalhub/routers/bands.py index 5ad7731..f727a84 100644 --- a/api/src/rehearsalhub/routers/bands.py +++ b/api/src/rehearsalhub/routers/bands.py @@ -6,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from rehearsalhub.db.engine import get_session from rehearsalhub.db.models import 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 router = APIRouter(prefix="/bands", tags=["bands"]) @@ -17,7 +18,6 @@ async def list_bands( session: AsyncSession = Depends(get_session), current_member: Member = Depends(get_current_member), ): - from rehearsalhub.repositories.band import BandRepository repo = BandRepository(session) bands = await repo.list_for_member(current_member.id) return [BandRead.model_validate(b) for b in bands] @@ -53,3 +53,29 @@ async def get_band( if band is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found") 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) diff --git a/api/src/rehearsalhub/schemas/band.py b/api/src/rehearsalhub/schemas/band.py index 6388ba6..238db6f 100644 --- a/api/src/rehearsalhub/schemas/band.py +++ b/api/src/rehearsalhub/schemas/band.py @@ -21,6 +21,10 @@ class BandCreate(BaseModel): 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): model_config = ConfigDict(from_attributes=True) id: uuid.UUID diff --git a/watcher/src/watcher/event_loop.py b/watcher/src/watcher/event_loop.py index 9d2136b..1e7267b 100644 --- a/watcher/src/watcher/event_loop.py +++ b/watcher/src/watcher/event_loop.py @@ -109,27 +109,25 @@ async def poll_once(nc_client: NextcloudWatcherClient, settings: WatcherSettings for activity in activities: 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) log.debug( - "Activity id=%d subject=%r path=%r", - activity_id, subject, raw_path, + "Activity id=%d type=%r subject=%r path=%r", + activity_id, activity_type, subject, raw_path, ) # Advance the cursor regardless of whether we act on this event _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: log.debug("Skipping activity %d: no file path in payload", activity_id) continue 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): log.debug( "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 + 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) etag = await nc_client.get_file_etag(nc_path) success = await register_version_with_api(nc_path, etag, settings.api_url) diff --git a/web/index.html b/web/index.html index 40d5b2c..c99c1bc 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,13 @@ - + + RehearsalHub diff --git a/web/src/App.tsx b/web/src/App.tsx index e2981a4..d8f10e4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; +import "./index.css"; +import { ThemeProvider, useTheme } from "./theme"; import { LoginPage } from "./pages/LoginPage"; import { HomePage } from "./pages/HomePage"; import { BandPage } from "./pages/BandPage"; @@ -16,40 +18,73 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { return token ? <>{children} : ; } -export default function App() { +function ThemeToggle() { + const { theme, toggle } = useTheme(); return ( - - - - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - - - + + ); +} + +export default function App() { + return ( + + + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + + + + ); } diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..9066ed7 --- /dev/null +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index ea3d1e5..d6a9650 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -34,6 +34,8 @@ export function BandPage() { const [error, setError] = useState(null); const [scanMsg, setScanMsg] = useState(null); const [inviteLink, setInviteLink] = useState(null); + const [editingFolder, setEditingFolder] = useState(false); + const [folderInput, setFolderInput] = useState(""); const { data: band, isLoading } = useQuery({ queryKey: ["band", bandId], @@ -92,56 +94,104 @@ export function BandPage() { onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }), }); - // We determine "am I admin?" from GET /auth/me cross-referenced with the members list. - // The simplest heuristic: the creator of the band (first admin in the list) is the current user - // if they appear with role=admin. We store the current member id in the JWT subject but don't - // expose it yet, so we compare by checking if the members list has exactly one admin and we - // can tell by the invite button being available on the backend (403 vs 201). - // For the UI we just show the Remove button for non-admin members and let the API enforce auth. - - if (isLoading) return
Loading...
; - if (!band) return
Band not found
; + const updateFolderMutation = useMutation({ + mutationFn: (nc_folder_path: string) => + api.patch(`/bands/${bandId}`, { nc_folder_path }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["band", bandId] }); + setEditingFolder(false); + }, + }); const amAdmin = members?.some((m) => m.role === "admin") ?? false; + if (isLoading) return
Loading...
; + if (!band) return
Band not found
; + return ( -
+
- + ← All Bands -
-

{band.name}

+ {/* ── Band header ── */} +
+

{band.name}

{band.genre_tags.length > 0 && (
{band.genre_tags.map((t: string) => ( - {t} + {t} ))}
)}
+ {/* ── Nextcloud folder ── */} +
+
+
+ NEXTCLOUD SCAN FOLDER +
+ {band.nc_folder_path ?? `bands/${band.slug}/`} +
+
+ {amAdmin && !editingFolder && ( + + )} +
+ {editingFolder && ( +
+ 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" }} + /> +
+ + +
+
+ )} +
+ {/* ── Members ── */}
-

Members

+

Members

{inviteLink && ( -
-

Invite link (copied to clipboard, valid 72h):

- {inviteLink} +
+

Invite link (copied to clipboard, valid 72h):

+ {inviteLink} @@ -152,25 +202,25 @@ export function BandPage() { {members?.map((m) => (
{m.display_name} - {m.email} + {m.email}
{m.role} {amAdmin && m.role !== "admin" && ( @@ -183,18 +233,18 @@ export function BandPage() { {/* ── Songs ── */}
-

Songs

+

Songs

@@ -202,36 +252,36 @@ export function BandPage() {
{scanMsg && ( -
+
{scanMsg}
)} {showCreate && ( -
- {error &&

{error}

} - +
+ {error &&

{error}

} + setTitle(e.target.value)} 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 /> -

- A folder bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/ will be created in Nextcloud. +

+ A folder bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/ will be created in Nextcloud.

@@ -244,18 +294,18 @@ export function BandPage() { {song.title} - - {song.status} + + {song.status} {song.version_count} version{song.version_count !== 1 ? "s" : ""} ))} {songs?.length === 0 && ( -

- No songs yet. Create one or scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}. +

+ No songs yet. Create one or scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}.

)}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 2f09624..73652ef 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -33,21 +33,32 @@ export function HomePage() { 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 ( -
+
-

◈ RehearsalHub

+

◈ RehearsalHub

@@ -55,56 +66,56 @@ export function HomePage() {
-

Your Bands

+

Your Bands

{showCreate && ( -
- {error &&

{error}

} - +
+ {error &&

{error}

} + { setName(e.target.value); 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} /> - + 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 }} /> -
diff --git a/web/src/pages/InvitePage.tsx b/web/src/pages/InvitePage.tsx index 444316f..134fcb2 100644 --- a/web/src/pages/InvitePage.tsx +++ b/web/src/pages/InvitePage.tsx @@ -47,29 +47,29 @@ export function InvitePage() { } return ( -
-
-

◈ RehearsalHub

-

Band invite

+
+
+

◈ RehearsalHub

+

Band invite

{error && ( -
+
{error}
)} {done && ( -
+
Joined! Redirecting…
)} {!done && invite && ( <> -

- You've been invited to join a band as {invite.role}. +

+ You've been invited to join a band as {invite.role}.

-

+

Expires {new Date(invite.expires_at).toLocaleDateString()} {invite.used_at && " · Already used"}

@@ -78,16 +78,16 @@ export function InvitePage() { ) : (
-

Log in or register to accept this invite.

+

Log in or register to accept this invite.

@@ -97,7 +97,7 @@ export function InvitePage() { )} {!done && !invite && !error && ( -

Loading invite…

+

Loading invite…

)}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 7d2c4a5..f353c49 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -28,43 +28,36 @@ export function LoginPage() { } const inputStyle: React.CSSProperties = { - width: "100%", padding: "10px 12px", background: "#131720", - border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", + width: "100%", padding: "10px 12px", + background: "var(--bg-inset)", + border: "1px solid var(--border)", + borderRadius: 6, + color: "var(--text)", 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 ( -
-
-

◈ RehearsalHub

-

+

+ +

◈ RehearsalHub

+

{mode === "login" ? "Sign in to your account" : "Create a new account"}

- {error &&

{error}

} + {error &&

{error}

} {mode === "register" && ( <> - setDisplayName(e.target.value)} - required - style={inputStyle} - /> + setDisplayName(e.target.value)} required style={inputStyle} /> )} - setEmail(e.target.value)} - required - style={inputStyle} - /> + setEmail(e.target.value)} required style={inputStyle} /> - -

+

{mode === "login" ? "Don't have an account? " : "Already have an account? "} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 3d0e668..d650901 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -20,7 +20,17 @@ const updateSettings = (data: { nc_password?: string; }) => api.patch("/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 }) { const qc = useQueryClient(); 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"), }); + const labelStyle: React.CSSProperties = { + display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6, + }; + return ( <>

-

PROFILE

- +

PROFILE

+ setDisplayName(e.target.value)} style={inputStyle} /> -

{me.email}

+

{me.email}

-

NEXTCLOUD CONNECTION

-

+

NEXTCLOUD CONNECTION

+

Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials.

- - + + {me.nc_configured ? "Connected" : "Not configured"}
- + setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} /> - + setNcUsername(e.target.value)} style={inputStyle} /> - + void }) { placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""} style={inputStyle} /> -

+

Use an app password from Nextcloud Settings → Security for better security.

- {error &&

{error}

} - {saved &&

Settings saved.

} + {error &&

{error}

} + {saved &&

Settings saved.

}
@@ -116,32 +130,21 @@ export function SettingsPage() { const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); return ( -
+
-

Settings

+

Settings

- {isLoading &&

Loading...

} + {isLoading &&

Loading...

} {me && navigate("/")} />}
); } - -const inputStyle: React.CSSProperties = { - width: "100%", - padding: "8px 12px", - background: "#131720", - border: "1px solid #1C2235", - borderRadius: 6, - color: "#E2E6F0", - fontSize: 14, - boxSizing: "border-box", -}; diff --git a/web/src/pages/SongPage.tsx b/web/src/pages/SongPage.tsx index 5f9b4d2..dd163df 100644 --- a/web/src/pages/SongPage.tsx +++ b/web/src/pages/SongPage.tsx @@ -74,8 +74,8 @@ export function SongPage() { }); return ( -
- +
+ ← Back to Band @@ -86,9 +86,10 @@ export function SongPage() { key={v.id} onClick={() => setSelectedVersionId(v.id)} style={{ - background: v.id === activeVersion ? "#2A1E08" : "#131720", - border: `1px solid ${v.id === activeVersion ? "#F0A840" : "#1C2235"}`, - borderRadius: 6, padding: "6px 14px", color: v.id === activeVersion ? "#F0A840" : "#5A6480", + background: v.id === activeVersion ? "var(--accent-bg)" : "var(--bg-inset)", + border: `1px solid ${v.id === activeVersion ? "var(--accent)" : "var(--border)"}`, + borderRadius: 6, padding: "6px 14px", + color: v.id === activeVersion ? "var(--accent)" : "var(--text-muted)", cursor: "pointer", fontSize: 12, fontFamily: "monospace", }} > @@ -98,21 +99,16 @@ export function SongPage() {
{/* Waveform */} -
{ - // TODO: seek on click (needs duration from wavesurfer) - }} - > +
- + {formatTime(currentTime)}
@@ -127,28 +123,28 @@ export function SongPage() { {/* Comments */}
-

COMMENTS

+

COMMENTS

{comments?.map((c) => ( -
+
- {c.author_name} + {c.author_name}
- {new Date(c.created_at).toLocaleString()} + {new Date(c.created_at).toLocaleString()}
-

{c.body}

+

{c.body}

))} {comments?.length === 0 && ( -

No comments yet. Be the first.

+

No comments yet. Be the first.

)}
@@ -158,12 +154,12 @@ export function SongPage() { onChange={(e) => setCommentBody(e.target.value)} placeholder="Add a comment…" 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" }} /> @@ -181,24 +177,24 @@ function AnnotationCard({ annotation: a, onSeek, versionId }: { annotation: Anno }); return ( -
+
- {a.type} - {a.label && {a.label}} + {a.type} + {a.label && {a.label}} {a.tags.map((t) => ( - {t} + {t} ))}
- {a.body &&

{a.body}

} + {a.body &&

{a.body}

} {a.range_analysis && ( -
+
{a.range_analysis.bpm && ♩ {a.range_analysis.bpm.toFixed(1)} BPM} {a.range_analysis.key && 🎵 {a.range_analysis.key}} {a.range_analysis.avg_loudness_lufs && {a.range_analysis.avg_loudness_lufs.toFixed(1)} LUFS} @@ -209,10 +205,10 @@ function AnnotationCard({ annotation: a, onSeek, versionId }: { annotation: Anno diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..31e86e8 --- /dev/null +++ b/web/src/theme.ts @@ -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({ theme: "dark", toggle: () => {} }); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState(() => { + 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); +}