From 7f58bad245481881b445586b99d71ca6ab0d6cde Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 30 Aug 2026 17:48:59 +0100 Subject: [PATCH] feat(frontend): add dark mode option (#34) Resolves #32 Reviewed-on: https://gitea.defaoite.dev/dylan/vod-system/pulls/34 --- frontend/index.html | 14 ++++ frontend/src/App.tsx | 43 +++++++------ frontend/src/components/Topbar.tsx | 25 ++++++-- frontend/src/components/video/VideoCard.tsx | 4 +- frontend/src/index.css | 71 ++++++++++++++++++++- frontend/src/pages/ClipEdit.tsx | 2 +- frontend/src/pages/MediaPlayer.tsx | 2 +- frontend/src/theme/ThemeContext.ts | 23 +++++++ frontend/src/theme/ThemeProvider.tsx | 71 +++++++++++++++++++++ frontend/src/theme/useTheme.ts | 14 ++++ 10 files changed, 239 insertions(+), 30 deletions(-) create mode 100644 frontend/src/theme/ThemeContext.ts create mode 100644 frontend/src/theme/ThemeProvider.tsx create mode 100644 frontend/src/theme/useTheme.ts diff --git a/frontend/index.html b/frontend/index.html index 3a9b00f..b72b9b3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,20 @@ + The VoD System diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d7bbac..a6f589c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index b80e4b6..a39fc6f 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -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 ? : } - { user ? ( +
+ + {theme === "dark" ? : } + + + { user ? (
- ) : - ( + ) : + ( handleLogin(credentialResponse)} /> - )} + )} +
) } diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index 7160b93..466ffb7 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -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" /> - + {formatTime(duration)} @@ -77,7 +77,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className > diff --git a/frontend/src/index.css b/frontend/src/index.css index 972e7f6..d4d417e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { @@ -87,4 +156,4 @@ body { #range-slider .range-slider__thumb:focus-visible { box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent); -} \ No newline at end of file +} diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index f40362e..3188ef3 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -135,7 +135,7 @@ const ClipEdit = () => {