Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import logo from "../assets/logo_light_160.png";
import { SideSheet } from "@douyinfe/semi-ui";
import { IconMenu } from "@douyinfe/semi-icons";
import { socials } from "../data/socials";
import { getTheme, toggleTheme, onSystemPrefChange } from "../theme";

export default function Navbar() {
const [openMenu, setOpenMenu] = useState(false);
const [isDark, setIsDark] = useState(() => getTheme() === "dark");

useEffect(() => {
// Keep local state synced when system preference changes (only when user hasn't chosen a theme)
const unsubscribe = onSystemPrefChange((prefersDark) => {
// if there's an explicit stored preference, ignore system changes
try {
const stored = window.localStorage && window.localStorage.getItem("drawdb:theme");
if (!stored) {
setIsDark(prefersDark);
}
} catch (e) {
// ignore storage errors
}
});

return () => unsubscribe && unsubscribe();
}, []);

return (
<>
Expand Down Expand Up @@ -75,12 +94,48 @@ export default function Navbar() {
</a>
</div>
</div>
<button
onClick={() => setOpenMenu((prev) => !prev)}
className="hidden md:inline-block h-[24px]"
>
<IconMenu size="extra-large" />
</button>
<div className="flex items-center gap-4">
<button
onClick={() => {
const next = toggleTheme();
setIsDark(next === "dark");
}}
role="switch"
aria-checked={isDark}
aria-label="Toggle dark mode"
className="inline-flex relative items-center justify-center w-10 h-10 rounded-full focus:outline-none focus:ring-2 focus:ring-sky-500"
title="Toggle theme"
>
{/* Sun / Moon SVGs — visible depending on state */}
<svg
className={`w-5 h-5 transition-opacity duration-200 ${isDark ? "opacity-0" : "opacity-100"}`}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M12 4V2M12 22v-2M4 12H2M22 12h-2M5 5l-1.5-1.5M20.5 20.5 19 19M19 5l1.5-1.5M4.5 19.5 6 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5"/>
</svg>
<svg
className={`w-5 h-5 absolute transition-opacity duration-200 ${isDark ? "opacity-100" : "opacity-0"}`}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>

<button
onClick={() => setOpenMenu((prev) => !prev)}
className="hidden md:inline-block h-[24px]"
aria-label="Open menu"
>
<IconMenu size="extra-large" />
</button>
</div>
</div>
<hr />
<SideSheet
Expand Down
70 changes: 70 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,76 @@
}
}

/* Theme variables --------------------------------------------------------- */
:root {
/* default (light) values — may be overridden by .theme-dark */
--bg: 255 255 255;
--text: 17 24 39; /* slate-900 */
--muted: 107 114 128; /* slate-500 */
--accent: 14 165 233; /* sky-400 */
--semi-grey-0: 255,255,255;
--semi-grey-1: 249,250,251;
--semi-grey-2: 241,245,249;
--semi-grey-3: 226,232,240;
--semi-grey-4: 203,213,225;
--semi-grey-9: 30,41,59;
--semi-blue-5: 14,165,233;
--semi-blue-6: 2,132,199;
--semi-color-text-1: rgb(var(--text));
--semi-color-bg-0: rgb(var(--bg));
--semi-color-bg-2: rgb(var(--bg));
--semi-color-bg-3: rgb(var(--bg));
}

/* Dark theme overrides */
.theme-dark,
html.theme-dark {
--bg: 17 24 39; /* slate-900 */
--text: 243 244 246; /* slate-50 */
--muted: 148 163 184; /* slate-400 */
--accent: 56 189 248; /* sky-400 bright */
--semi-grey-0: 17,24,39;
--semi-grey-1: 20,23,29;
--semi-grey-2: 30,41,59;
--semi-grey-3: 39,47,61;
--semi-grey-4: 55,65,81;
--semi-grey-9: 243,244,246;
--semi-blue-5: 56,189,248;
--semi-blue-6: 14,165,233;
--semi-color-text-1: rgb(var(--text));
--semi-color-bg-0: rgb(var(--bg));
--semi-color-bg-2: rgb(var(--bg));
--semi-color-bg-3: rgb(var(--bg));
}

/* Light theme explicit class (optional; root defaults to light) */
.theme-light,
html.theme-light {
/* no-op; variables already set in :root for light */
}

/* apply variables to body for layout */
body {
background-color: rgb(var(--bg));
color: rgb(var(--text));
}

/* Reduced motion: disable transitions */
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}

/* Subtle color transition when toggling theme */
@media (prefers-reduced-motion: no-preference) {
:root,
html {
transition: color 220ms ease, background-color 220ms ease;
}
}

.semi-form-vertical .semi-form-field {
margin: 0;
padding-top: 8px !important;
Expand Down
9 changes: 9 additions & 0 deletions src/pages/THEME_TOGGLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Theme toggle (light / dark)
--------------------------------

The landing page includes a theme toggle in the top-right header. It persists the user's choice in localStorage using the key `drawdb:theme` and falls back to the system preference; if neither is available the default is dark.

Files:
- `src/theme.js` — theme helper (init, toggle, storage, system listener)
- `src/components/Navbar.jsx` — toggle button UI and ARIA attributes
- `src/index.css` — CSS variables and `.theme-dark` / `.theme-light` classes
124 changes: 124 additions & 0 deletions src/theme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Theme helper for landing page
// Responsibilities:
// - read/write `drawdb:theme` from localStorage (safely)
// - apply `.theme-dark` or `.theme-light` class to document element
// - provide toggle function
// - listen to system preference changes and notify

const STORAGE_KEY = "drawdb:theme";

function safeGetStorage() {
try {
if (typeof window === "undefined" || !window.localStorage) return null;
return window.localStorage;
} catch (e) {
return null;
}
}

function getStoredTheme() {
const s = safeGetStorage();
try {
return s ? s.getItem(STORAGE_KEY) : null;
} catch (e) {
return null;
}
}

function setStoredTheme(value) {
const s = safeGetStorage();
try {
if (s) s.setItem(STORAGE_KEY, value);
} catch (e) {
// ignore storage errors (e.g., private mode)
}
}

function isSystemDark() {
try {
if (typeof window === "undefined" || !window.matchMedia) return true; // fallback to dark
return window.matchMedia("(prefers-color-scheme: dark)").matches;
} catch (e) {
return true;
}
}

function applyThemeClass(theme) {
try {
const root = document.documentElement || document.body;
if (!root) return;
root.classList.remove("theme-dark", "theme-light");
if (theme === "dark") root.classList.add("theme-dark");
else root.classList.add("theme-light");
} catch (e) {
// no-op
}
}

// Determine active theme: stored > system > default dark
export function getTheme() {
const stored = getStoredTheme();
if (stored === "dark" || stored === "light") return stored;
const sys = isSystemDark() ? "dark" : "light";
return sys || "dark";
}

export function applyTheme(theme) {
applyThemeClass(theme);
}

// toggle and persist
export function toggleTheme() {
const current = getTheme();
const next = current === "dark" ? "light" : "dark";
try {
setStoredTheme(next);
} catch (e) {
// ignore
}
applyThemeClass(next);
return next;
}

// Initialize on page load: apply stored or system preference, defaulting to dark
export function initTheme() {
const stored = getStoredTheme();
const theme = stored === "dark" || stored === "light" ? stored : (isSystemDark() ? "dark" : "light");
applyThemeClass(theme);
}

// Listen to system preference changes and call callback(prefersDark)
// Returns an unsubscribe function
export function onSystemPrefChange(cb) {
try {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (ev) => cb(!!ev.matches);
// modern API
if (mq.addEventListener) mq.addEventListener("change", handler);
else mq.addListener && mq.addListener(handler);
return () => {
if (mq.removeEventListener) mq.removeEventListener("change", handler);
else mq.removeListener && mq.removeListener(handler);
};
} catch (e) {
return () => {};
}
}

// Auto-init if running in browser
if (typeof window !== "undefined") {
try {
initTheme();
} catch (e) {
// ignore
}
}

export default {
getTheme,
applyTheme,
toggleTheme,
initTheme,
onSystemPrefChange,
};