feat(frontend): add dark mode option #34
@@ -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>
|
||||
|
||||
@@ -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,6 +28,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
@@ -47,6 +49,7 @@ function App() {
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ GoogleOAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,6 +56,15 @@ const Topbar = ({
|
||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||
</MenuButton>
|
||||
|
||||
<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
|
||||
@@ -82,10 +95,12 @@ const Topbar = ({
|
||||
(
|
||||
<GoogleLogin
|
||||
shape={"pill"}
|
||||
theme={theme === "dark" ? "filled_black" : "outline"}
|
||||
useOneTap={false}
|
||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
23
frontend/src/theme/ThemeContext.ts
Normal file
23
frontend/src/theme/ThemeContext.ts
Normal 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 };
|
||||
71
frontend/src/theme/ThemeProvider.tsx
Normal file
71
frontend/src/theme/ThemeProvider.tsx
Normal 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;
|
||||
14
frontend/src/theme/useTheme.ts
Normal file
14
frontend/src/theme/useTheme.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user