feat(frontend): add dark mode option #34

Merged
dylan merged 2 commits from feat/add-dark-mode-frontend into master 2026-08-30 17:49:00 +01:00
10 changed files with 239 additions and 30 deletions

View File

@@ -9,6 +9,20 @@
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
// Apply the stored theme before first paint so a dark session never
// flashes light. Mirrors the logic in src/theme/ThemeProvider.tsx.
(function () {
try {
var stored = localStorage.getItem("theme");
var dark = stored === "dark" ||
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
<script src="/env-config.js"></script>
<title>The VoD System</title>
</head>

View File

@@ -18,6 +18,7 @@ import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
import ThemeProvider from "./theme/ThemeProvider.tsx";
function App() {
@@ -27,26 +28,28 @@ function App() {
return (
<GoogleOAuthProvider clientId={config.googleClientId}>
<AuthProvider>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
</ThemeProvider>
</ GoogleOAuthProvider>
);
}

View File

@@ -1,4 +1,4 @@
import { Menu, X } from 'lucide-react';
import { Menu, X, Sun, Moon } from 'lucide-react';
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
@@ -8,6 +8,7 @@ import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
import { useTheme } from "../theme/useTheme.ts";
type props = {
@@ -25,6 +26,7 @@ const Topbar = ({
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
@@ -38,6 +40,8 @@ const Topbar = ({
});
}
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
const handleLogout = () => {
logout()
.then(() => navigate("/"))
@@ -52,7 +56,16 @@ const Topbar = ({
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div className="flex items-center gap-1">
<MenuButton
onClick={toggleTheme}
title={themeLabel}
aria-label={themeLabel}
>
{theme === "dark" ? <Sun size={20}/> : <Moon size={20}/>}
</MenuButton>
{ user ? (
<div className={"hover:bg-hover rounded-lg p-0.5"}>
<img
className={"w-8 h-8 rounded-full inline-block"}
@@ -78,13 +91,15 @@ const Topbar = ({
className={"text-error font-medium"} />
</Dropdown>
</div>
) :
(
) :
(
<GoogleLogin
shape={"pill"}
theme={theme === "dark" ? "filled_black" : "outline"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
)}
</div>
</div>
)
}

View File

@@ -65,7 +65,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<span className="absolute top-1.5 left-1.5 bg-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
<span className="absolute top-1.5 left-1.5 bg-media-backdrop/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
{formatTime(duration)}
</span>
@@ -77,7 +77,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
className="bg-media-backdrop/75 hover:bg-media-backdrop/90 text-on-dark rounded p-0.5 transition-colors"
>
<MoreVertical size={14} />
</button>

View File

@@ -1,5 +1,10 @@
@import "tailwindcss";
/* Drive the `dark:` variant off the same data-theme attribute the toggle sets,
instead of Tailwind's default prefers-color-scheme. Without this a stray
`dark:` utility would disagree with an explicit light/dark choice. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme {
/* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
@@ -12,6 +17,10 @@
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
/* Letterbox behind video. Dark in both themes — it is a cinema surface,
not an ink colour — so `text-on-dark` always reads on top of it. */
--color-media-backdrop: #2B2620;
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
@@ -44,6 +53,55 @@
--color-inactive-strong: #C4BAA9;
}
/* Dark mode. Only token values change here: every component already styles
itself with the semantic utilities above, so the whole UI follows from this
one block. `--color-on-dark` is deliberately absent — it is the ink on the
media backdrop, which stays dark in both themes. */
:root[data-theme="dark"] {
color-scheme: dark;
/* ── Surfaces ── elevation inverts: the canvas is darkest, cards rise ─── */
--color-background: #1B1815;
--color-sidebar: #151210;
--color-card: #26211C;
--color-fields: #211D19;
--color-dropzone: #201C18;
--color-media-backdrop: #121010;
/* ── Ink ── warm off-whites, mirroring the light ramp in reverse ──────── */
--color-text-primary: #F3EDE3;
--color-text-strong: #E6DFD3;
--color-text-body: #CFC6B8;
--color-text-secondary: #ABA192;
--color-muted: #948A7C;
--color-faint-mono: #8A8073;
/* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
rather than deepens ─────────────────────────────────────────────────── */
--color-terracotta: #D08055;
--color-terracotta-hover: #E09A6F;
--color-terracotta-light: #E3A87F;
--color-terracotta-disabled: #8A6A55;
--color-olive: #9DAC77;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #E0705A;
--color-error: #E0705A;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #241B14;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #2E2822;
--color-hover: #322B24;
--color-selected: #2E2119;
--color-ring: #3E362C;
--color-inactive: #3B342B;
--color-inactive-strong: #6B6153;
}
:root {
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
@@ -52,6 +110,7 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color-scheme: light;
}
body {
@@ -66,6 +125,16 @@ body {
color: var(--color-text-primary);
}
/* Google Identity Services renders its sign-in button in a cross-origin iframe
whose document is light-scheme with a transparent background. Left to inherit
our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
white canvas behind it instead — framing the pill in a white box on every dark
page. Pinning the iframe back to light keeps it transparent; the button's own
colours come from the `theme` prop in Topbar.tsx. */
iframe[src*="accounts.google.com/gsi/"] {
color-scheme: light;
}
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
defaults; the #id beats those class selectors regardless of import order. */
#range-slider {

View File

@@ -135,7 +135,7 @@ const ClipEdit = () => {
<video
ref={videoRef}
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-text-primary"}
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>

View File

@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Player */}
<div
ref={containerRef}
className="relative bg-text-primary rounded-xl overflow-hidden flex-1 min-h-0 select-none"
className="relative bg-media-backdrop rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}

View File

@@ -0,0 +1,23 @@
import { createContext } from "react";
/** What the user chose. "system" defers to the OS setting. */
type ThemePreference = "light" | "dark" | "system";
/** The theme actually on screen, once "system" has been resolved. */
type ResolvedTheme = "light" | "dark";
type ThemeContextValue = {
/** The stored choice, which may be "system". */
preference: ThemePreference;
/** The theme currently applied to the document. */
theme: ResolvedTheme;
/** Store a new preference and apply it immediately. */
setPreference: (preference: ThemePreference) => void;
/** Flip between light and dark, pinning the result as an explicit choice. */
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export { ThemeContext };
export type { ThemeContextValue, ThemePreference, ResolvedTheme };

View File

@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { ThemeContext } from "./ThemeContext.ts";
import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
/** Shared with the pre-paint script in index.html — keep the two in step. */
const STORAGE_KEY = "theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";
const readPreference = (): ThemePreference => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
} catch {
// Storage blocked (private browsing, cookies disabled) — follow the OS.
}
return "system";
};
const readSystemTheme = (): ResolvedTheme =>
window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
/**
* Owns the single source of truth for the active theme. Nothing reads colours
* from here: the palette lives in index.css and switches on the `data-theme`
* attribute this provider writes, so components keep using the semantic
* utilities (`bg-card`, `text-text-primary`) and follow along for free.
*/
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [preference, setPreferenceState] = useState<ThemePreference>(readPreference);
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
// Keep following the OS while the preference is "system".
useEffect(() => {
const query = window.matchMedia(DARK_QUERY);
const onChange = (event: MediaQueryListEvent) =>
setSystemTheme(event.matches ? "dark" : "light");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
const setPreference = useCallback((next: ThemePreference) => {
setPreferenceState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Storage blocked — the choice still applies for this session.
}
}, []);
const toggleTheme = useCallback(() => {
setPreference(theme === "dark" ? "light" : "dark");
}, [theme, setPreference]);
return (
<ThemeContext.Provider value={{ preference, theme, setPreference, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export default ThemeProvider;

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import { ThemeContext } from "./ThemeContext.ts";
import type { ThemeContextValue } from "./ThemeContext.ts";
/** Access the centralised theme state. Must be used within a <ThemeProvider>. */
const useTheme = (): ThemeContextValue => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
export { useTheme };