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">
|
<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" />
|
<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>
|
<script src="/env-config.js"></script>
|
||||||
<title>The VoD System</title>
|
<title>The VoD System</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { GoogleOAuthProvider } from '@react-oauth/google';
|
|||||||
import {config} from "./config.ts";
|
import {config} from "./config.ts";
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
import NotFound from "./pages/NotFound.tsx";
|
||||||
import AuthProvider from "./auth/AuthProvider.tsx";
|
import AuthProvider from "./auth/AuthProvider.tsx";
|
||||||
|
import ThemeProvider from "./theme/ThemeProvider.tsx";
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -27,6 +28,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -47,6 +49,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ GoogleOAuthProvider>
|
</ 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 { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import MenuButton from "./buttons/MenuButton.tsx";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { useAuth } from "../auth/useAuth.ts";
|
||||||
|
import { useTheme } from "../theme/useTheme.ts";
|
||||||
|
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -25,6 +26,7 @@ const Topbar = ({
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, login, logout } = useAuth();
|
const { user, login, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
if (!response.credential) {
|
if (!response.credential) {
|
||||||
@@ -38,6 +40,8 @@ const Topbar = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
.then(() => navigate("/"))
|
.then(() => navigate("/"))
|
||||||
@@ -52,6 +56,15 @@ const Topbar = ({
|
|||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</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 ? (
|
{ user ? (
|
||||||
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
||||||
<img
|
<img
|
||||||
@@ -82,10 +95,12 @@ const Topbar = ({
|
|||||||
(
|
(
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
shape={"pill"}
|
shape={"pill"}
|
||||||
|
theme={theme === "dark" ? "filled_black" : "outline"}
|
||||||
useOneTap={false}
|
useOneTap={false}
|
||||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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"
|
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)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
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} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
@theme {
|
||||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
||||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
@@ -12,6 +17,10 @@
|
|||||||
--color-fields: #F4F0E8;
|
--color-fields: #F4F0E8;
|
||||||
--color-dropzone: #F9F6EF;
|
--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 ─────────────────────────────────────────────────────────────── */
|
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||||
--color-text-primary: #2B2620;
|
--color-text-primary: #2B2620;
|
||||||
--color-text-strong: #35302A;
|
--color-text-strong: #35302A;
|
||||||
@@ -44,6 +53,55 @@
|
|||||||
--color-inactive-strong: #C4BAA9;
|
--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 {
|
:root {
|
||||||
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -52,6 +110,7 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -66,6 +125,16 @@ body {
|
|||||||
color: var(--color-text-primary);
|
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
|
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
||||||
defaults; the #id beats those class selectors regardless of import order. */
|
defaults; the #id beats those class selectors regardless of import order. */
|
||||||
#range-slider {
|
#range-slider {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const ClipEdit = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-text-primary"}
|
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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}
|
onMouseMove={showControls}
|
||||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
onClick={togglePlay}
|
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