Add dark mode and theme toggle

This commit is contained in:
yoma committed 2026-07-11 22:49:03 +08:00
1 parent f72f4ba71b
commit d8edc8b252
13 files changed
+465 -10

No files matched your search

+7 -5
View File
@@ -50,6 +50,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { EditorToolbar } from "./EditorToolbar";
import { ThemeToggle } from "./ThemeToggle";
import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog";
import { api } from "@/lib/api";
import { consumeStandaloneMobileEditorReturn, openStandaloneMobileEditor } from "@/lib/mobile-editor";
@@ -2330,13 +2331,14 @@ const RichEditorPane = ({
<Type className="h-4 w-4" />
</Button>
)}
<Button className="hidden sm:inline-flex" size="icon" variant="ghost" title="搜索当前笔记" aria-label="搜索当前笔记" onClick={() => openNoteSearch()}>
<Search className="h-4 w-4" />
<Button className="hidden h-8 w-8 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:ring-2 focus-visible:ring-slate-300 sm:inline-flex" size="icon" variant="ghost" title="搜索当前笔记" aria-label="搜索当前笔记" onClick={() => openNoteSearch()}>
<Search className="h-5 w-5" strokeWidth={2.25} />
</Button>
<Button className="hidden sm:inline-flex" size="icon" variant="ghost" title="版本历史" aria-label="版本历史" onClick={() => setHistoryOpen(true)}>
<History className="h-4 w-4" />
<Button className="hidden h-8 w-8 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:ring-2 focus-visible:ring-slate-300 sm:inline-flex" size="icon" variant="ghost" title="版本历史" aria-label="版本历史" onClick={() => setHistoryOpen(true)}>
<History className="h-5 w-5" strokeWidth={2.25} />
</Button>
<GitHubRepositoryLink className="hidden h-8 w-8 justify-center rounded-md text-slate-500 transition hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 lg:inline-flex" />
<GitHubRepositoryLink className="hidden h-8 w-8 justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 lg:inline-flex" iconClassName="h-5 w-5" />
<ThemeToggle />
{!readOnly && (
<Button
className="hidden sm:inline-flex"
+2
View File
@@ -10,6 +10,7 @@ import { PwaInstallCard } from "./settings/PwaInstallCard";
import { PasswordCard } from "./settings/PasswordCard";
import { SessionCard } from "./settings/SessionCard";
import { SystemInfoCard } from "./settings/SystemInfoCard";
import { ThemeToggle } from "./ThemeToggle";
interface SettingsPaneProps {
onClose: () => void;
@@ -58,6 +59,7 @@ export const SettingsPane = ({
<p className="mt-0.5 truncate text-xs font-medium text-slate-400">{t("settings.subtitle")}</p>
</div>
</div>
<ThemeToggle />
</header>
<div className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-4 py-4 lg:px-6 lg:py-6">
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type ThemePreference = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
interface ThemeContextValue {
preference: ThemePreference;
resolvedTheme: ResolvedTheme;
setPreference: (preference: ThemePreference) => void;
}
interface ThemeProviderProps {
children: ReactNode;
}
const THEME_STORAGE_KEY = "edgeever.theme";
const LIGHT_THEME_COLOR = "#f8fafc";
const DARK_THEME_COLOR = "#0f172a";
const ThemeContext = createContext<ThemeContextValue | null>(null);
const getSystemTheme = () =>
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const resolveTheme = (preference: ThemePreference): ResolvedTheme =>
preference === "system" ? getSystemTheme() : preference;
export const getStoredThemePreference = (): ThemePreference => {
if (typeof window === "undefined") {
return "system";
}
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
};
const applyThemeToDocument = (preference: ThemePreference) => {
const resolvedTheme = resolveTheme(preference);
const root = document.documentElement;
root.classList.toggle("dark", resolvedTheme === "dark");
root.style.colorScheme = resolvedTheme;
const themeColor = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
themeColor?.setAttribute("content", resolvedTheme === "dark" ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
return resolvedTheme;
};
export const initializeTheme = () => {
applyThemeToDocument(getStoredThemePreference());
};
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [preference, setPreferenceState] = useState<ThemePreference>(getStoredThemePreference);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(preference));
useEffect(() => {
setResolvedTheme(applyThemeToDocument(preference));
window.localStorage.setItem(THEME_STORAGE_KEY, preference);
if (preference !== "system") {
return undefined;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemThemeChange = () => setResolvedTheme(applyThemeToDocument("system"));
mediaQuery.addEventListener("change", handleSystemThemeChange);
return () => mediaQuery.removeEventListener("change", handleSystemThemeChange);
}, [preference]);
const value = useMemo(
() => ({
preference,
resolvedTheme,
setPreference: (nextPreference: ThemePreference) => setPreferenceState(nextPreference),
}),
[preference, resolvedTheme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
};
+24
View File
@@ -0,0 +1,24 @@
import { MoonStar, SunMedium } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useTheme } from "./ThemeProvider";
export const ThemeToggle = () => {
const { t } = useTranslation();
const { resolvedTheme, setPreference } = useTheme();
const nextTheme = resolvedTheme === "dark" ? "light" : "dark";
const label = nextTheme === "dark" ? t("settings.themeToggleToDark") : t("settings.themeToggleToLight");
return (
<Button
className="hidden h-8 w-8 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:ring-2 focus-visible:ring-slate-300 lg:inline-flex"
size="icon"
variant="ghost"
title={label}
aria-label={label}
onClick={() => setPreference(nextTheme)}
>
{resolvedTheme === "dark" ? <SunMedium className="h-5 w-5" strokeWidth={2.25} /> : <MoonStar className="h-5 w-5" strokeWidth={2.25} />}
</Button>
);
};
@@ -13,6 +13,7 @@ import {
type AppLocalePreference,
} from "@/i18n";
import { ShortcutSettingsItem } from "./ShortcutSettingsItem";
import { useTheme, type ThemePreference } from "../ThemeProvider";
interface PreferenceCardProps {
imageCompressionEnabled: boolean;
@@ -28,6 +29,7 @@ export const PreferenceCard = ({
onShortcutSettingsChange,
}: PreferenceCardProps) => {
const { t } = useTranslation();
const { preference: themePreference, setPreference: setThemePreference } = useTheme();
const [activeLocalePreference, setActiveLocalePreference] = useState<AppLocalePreference>(() => getAppLocalePreference());
const handleLocalePreferenceChange = (preference: AppLocalePreference) => {
@@ -82,6 +84,28 @@ export const PreferenceCard = ({
/>
</div>
</div>
<div className="flex min-h-14 flex-col items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/70 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">{t("settings.themeTitle")}</div>
<div className="mt-0.5 text-xs leading-4 text-slate-500">{t("settings.themeDescription")}</div>
</div>
<div className="w-full shrink-0 sm:w-44">
<Select
value={themePreference}
onValueChange={(preference) => setThemePreference(preference as ThemePreference)}
>
<SelectTrigger aria-label={t("settings.themeTitle")} className="h-9 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t("settings.themeSystem")}</SelectItem>
<SelectItem value="light">{t("settings.themeLight")}</SelectItem>
<SelectItem value="dark">{t("settings.themeDark")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<ShortcutSettingsItem
shortcutSettings={shortcutSettings}
onShortcutSettingsChange={onShortcutSettingsChange}
+7
View File
@@ -139,6 +139,13 @@ export const enUS = {
imageCompressionTitle: "Compress note images",
imageCompressionDescription: "Compress large images locally before upload to reduce storage use.",
imageCompressionAria: "Compress note images",
themeTitle: "Interface theme",
themeDescription: "Choose light, dark, or follow your system setting.",
themeSystem: "Follow system",
themeLight: "Light",
themeDark: "Dark",
themeToggleToDark: "Switch to dark mode",
themeToggleToLight: "Switch to light mode",
},
systemInfo: {
title: "System info",
+7
View File
@@ -139,6 +139,13 @@ export const zhCN = {
imageCompressionTitle: "压缩笔记内图片",
imageCompressionDescription: "上传大图时在本地压缩,节省资源占用。",
imageCompressionAria: "是否压缩笔记内图片",
themeTitle: "界面主题",
themeDescription: "选择浅色、深色,或跟随系统设置。",
themeSystem: "跟随系统",
themeLight: "浅色",
themeDark: "深色",
themeToggleToDark: "切换到深色模式",
themeToggleToLight: "切换到浅色模式",
},
systemInfo: {
title: "系统信息",
+8 -3
View File
@@ -6,6 +6,7 @@ import { registerSW } from "virtual:pwa-register";
import { App } from "./app/App";
import "./i18n";
import { emitPwaUpdateNotice, markPwaUpdateReloadPending } from "./lib/pwa-update-notice";
import { initializeTheme, ThemeProvider } from "./components/ThemeProvider";
import "./styles/globals.css";
const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000;
@@ -58,12 +59,16 @@ if (!root) {
throw new Error("Root element not found");
}
initializeTheme();
createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>
);
+3
View File
@@ -1,6 +1,7 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { MobileStandaloneTiptapEditor } from "@/components/MobileStandaloneTiptapEditor";
import { initializeTheme } from "@/components/ThemeProvider";
import "./styles/mobile-markdown-editor.css";
const root = document.getElementById("mobile-editor-root");
@@ -9,6 +10,8 @@ if (!root) {
throw new Error("Mobile editor root not found");
}
initializeTheme();
createRoot(root).render(
<React.StrictMode>
<MobileStandaloneTiptapEditor />
+219
View File
@@ -25,6 +25,17 @@
--brand-green-800-rgb: 0 92 25;
--brand-green-900-rgb: 0 67 18;
--brand-green-950-rgb: 0 43 11;
--slate-50-rgb: 248 250 252;
--slate-100-rgb: 241 245 249;
--slate-200-rgb: 226 232 240;
--slate-300-rgb: 203 213 225;
--slate-400-rgb: 148 163 184;
--slate-500-rgb: 100 116 139;
--slate-600-rgb: 71 85 105;
--slate-700-rgb: 51 65 85;
--slate-800-rgb: 30 41 59;
--slate-900-rgb: 15 23 42;
--dark-surface-rgb: 255 255 255;
--editor-body-font-size: 0.9375rem;
--editor-body-line-height: 1.6;
--editor-paragraph-spacing: 0.45rem;
@@ -66,6 +77,59 @@
-moz-osx-font-smoothing: grayscale;
}
:root.dark {
color-scheme: dark;
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 217 33% 17%;
--card-foreground: 210 40% 98%;
--popover: 217 33% 17%;
--popover-foreground: 210 40% 98%;
--primary: 142 71% 45%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 24%;
--secondary-foreground: 142 71% 75%;
--muted: 217 33% 24%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 24%;
--accent-foreground: 142 71% 75%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--border: 215 28% 28%;
--input: 215 28% 28%;
--ring: 142 71% 45%;
--slate-50-rgb: 15 23 42;
--slate-100-rgb: 30 41 59;
--slate-200-rgb: 51 65 85;
--slate-300-rgb: 71 85 105;
--slate-400-rgb: 100 116 139;
--slate-500-rgb: 148 163 184;
--slate-600-rgb: 203 213 225;
--slate-700-rgb: 226 232 240;
--slate-800-rgb: 241 245 249;
--slate-900-rgb: 248 250 252;
--dark-surface-rgb: 30 41 59;
--brand-green: #4ade80;
--brand-green-hover: #22c55e;
--brand-green-muted: #14532d;
--brand-green-soft: #052e16;
--brand-green-border: #166534;
--brand-green-text: #86efac;
--brand-green-rgb: 74 222 128;
--brand-green-hover-rgb: 34 197 94;
--brand-green-50-rgb: 20 83 45;
--brand-green-100-rgb: 22 101 52;
--brand-green-200-rgb: 34 133 67;
--brand-green-300-rgb: 74 222 128;
--brand-green-400-rgb: 134 239 172;
--brand-green-500-rgb: 74 222 128;
--brand-green-600-rgb: 34 197 94;
--brand-green-700-rgb: 134 239 172;
--brand-green-800-rgb: 187 247 208;
--brand-green-900-rgb: 220 252 231;
--brand-green-950-rgb: 240 253 244;
}
html,
body,
#root {
@@ -90,6 +154,11 @@ body {
overscroll-behavior-y: auto;
}
:root.dark body {
background: #0f172a;
color: #f8fafc;
}
button,
input,
textarea {
@@ -457,3 +526,153 @@ textarea {
.markdown-content a:hover {
color: var(--brand-green);
}
/* Theme-aware surfaces for legacy white utilities and fixed semantic colors. */
:root.dark [class~="bg-white"] {
background-color: rgb(var(--dark-surface-rgb)) !important;
}
:root.dark [class~="bg-white/70"] {
background-color: rgb(var(--dark-surface-rgb) / 0.7) !important;
}
:root.dark [class~="bg-white/75"] {
background-color: rgb(var(--dark-surface-rgb) / 0.75) !important;
}
:root.dark [class~="bg-white/80"] {
background-color: rgb(var(--dark-surface-rgb) / 0.8) !important;
}
:root.dark [class~="bg-white/85"] {
background-color: rgb(var(--dark-surface-rgb) / 0.85) !important;
}
:root.dark [class~="bg-white/90"] {
background-color: rgb(var(--dark-surface-rgb) / 0.9) !important;
}
:root.dark [class~="bg-white/95"] {
background-color: rgb(var(--dark-surface-rgb) / 0.95) !important;
}
:root.dark [class~="border-white"] {
border-color: rgb(var(--slate-200-rgb)) !important;
}
:root.dark [class~="text-slate-950"] {
color: hsl(var(--foreground)) !important;
}
:root.dark [class~="bg-slate-50"],
:root.dark [class*="bg-slate-50/"] { background-color: rgb(var(--slate-50-rgb)) !important; }
:root.dark [class~="bg-slate-100"],
:root.dark [class*="bg-slate-100/"] { background-color: rgb(var(--slate-100-rgb)) !important; }
:root.dark [class~="bg-slate-200"],
:root.dark [class*="bg-slate-200/"] { background-color: rgb(var(--slate-200-rgb)) !important; }
:root.dark [class~="bg-slate-300"],
:root.dark [class*="bg-slate-300/"] { background-color: rgb(var(--slate-300-rgb)) !important; }
:root.dark [class~="bg-slate-400"],
:root.dark [class*="bg-slate-400/"] { background-color: rgb(var(--slate-400-rgb)) !important; }
:root.dark [class~="bg-slate-500"],
:root.dark [class*="bg-slate-500/"] { background-color: rgb(var(--slate-500-rgb)) !important; }
:root.dark [class~="bg-slate-600"],
:root.dark [class*="bg-slate-600/"] { background-color: rgb(var(--slate-600-rgb)) !important; }
:root.dark [class~="bg-slate-700"],
:root.dark [class*="bg-slate-700/"] { background-color: rgb(var(--slate-700-rgb)) !important; }
:root.dark [class~="bg-slate-800"],
:root.dark [class*="bg-slate-800/"] { background-color: rgb(var(--slate-800-rgb)) !important; }
:root.dark [class~="bg-slate-900"],
:root.dark [class*="bg-slate-900/"] { background-color: rgb(var(--slate-900-rgb)) !important; }
:root.dark [class~="text-slate-50"] { color: rgb(var(--slate-50-rgb)) !important; }
:root.dark [class~="text-slate-100"] { color: rgb(var(--slate-100-rgb)) !important; }
:root.dark [class~="text-slate-200"] { color: rgb(var(--slate-200-rgb)) !important; }
:root.dark [class~="text-slate-300"] { color: rgb(var(--slate-300-rgb)) !important; }
:root.dark [class~="text-slate-400"] { color: rgb(var(--slate-400-rgb)) !important; }
:root.dark [class~="text-slate-500"] { color: rgb(var(--slate-500-rgb)) !important; }
:root.dark [class~="text-slate-600"] { color: rgb(var(--slate-600-rgb)) !important; }
:root.dark [class~="text-slate-700"] { color: rgb(var(--slate-700-rgb)) !important; }
:root.dark [class~="text-slate-800"] { color: rgb(var(--slate-800-rgb)) !important; }
:root.dark [class~="text-slate-900"] { color: rgb(var(--slate-900-rgb)) !important; }
:root.dark [class~="border-slate-50"],
:root.dark [class*="border-slate-50/"] { border-color: rgb(var(--slate-50-rgb)) !important; }
:root.dark [class~="border-slate-100"],
:root.dark [class*="border-slate-100/"] { border-color: rgb(var(--slate-100-rgb)) !important; }
:root.dark [class~="border-slate-200"],
:root.dark [class*="border-slate-200/"] { border-color: rgb(var(--slate-200-rgb)) !important; }
:root.dark [class~="border-slate-300"],
:root.dark [class*="border-slate-300/"] { border-color: rgb(var(--slate-300-rgb)) !important; }
:root.dark [class~="border-slate-400"],
:root.dark [class*="border-slate-400/"] { border-color: rgb(var(--slate-400-rgb)) !important; }
:root.dark [class*="hover:bg-slate-50"]:hover,
:root.dark [class*="hover:bg-slate-100"]:hover { background-color: rgb(var(--slate-100-rgb)) !important; }
:root.dark [class*="hover:text-slate-900"]:hover,
:root.dark [class*="focus:text-slate-900"]:focus { color: rgb(var(--slate-900-rgb)) !important; }
:root.dark [class~="bg-amber-50"],
:root.dark [class~="bg-amber-50/95"] {
background-color: #451a03 !important;
}
:root.dark [class~="text-amber-700"],
:root.dark [class~="text-amber-800"] {
color: #fcd34d !important;
}
:root.dark [class~="bg-rose-50"],
:root.dark [class~="bg-rose-50/30"] {
background-color: #4c0519 !important;
}
:root.dark [class~="text-rose-600"],
:root.dark [class~="text-rose-700"] {
color: #fda4af !important;
}
:root.dark [class~="border-rose-100"],
:root.dark [class~="border-rose-200"] {
border-color: #881337 !important;
}
:root.dark .ProseMirror blockquote {
color: #cbd5e1;
background: var(--brand-green-soft);
}
:root.dark .ProseMirror code,
:root.dark .ProseMirror pre {
border-color: #166534;
background: #13261a;
color: #cbd5e1;
}
:root.dark .markdown-content {
color: #cbd5e1;
}
:root.dark .markdown-content h1,
:root.dark .markdown-content h2,
:root.dark .markdown-content h3 {
color: #f8fafc;
}
:root.dark .markdown-content h1,
:root.dark .markdown-content hr {
border-color: #334155;
}
:root.dark .markdown-content pre {
background-color: #13261a;
border-color: #166534;
color: #cbd5e1;
}
:root.dark .markdown-content code {
background-color: #13261a;
border-color: #166534;
color: #cbd5e1;
}
@@ -256,6 +256,64 @@ input {
padding: 8px 0 max(10px, env(safe-area-inset-bottom));
}
:root.dark {
color-scheme: dark;
}
:root.dark body,
:root.dark .mobile-editor-shell {
background: #0f172a;
color: #f8fafc;
}
:root.dark .mobile-editor-header,
:root.dark .mobile-editor-title,
:root.dark .mobile-editor-tags,
:root.dark .mobile-editor-notebook-sheet {
background: #1e293b;
color: #f8fafc;
}
:root.dark .mobile-editor-header,
:root.dark .mobile-editor-tool-row {
border-color: #334155;
}
:root.dark .mobile-editor-back,
:root.dark .mobile-editor-tool-button {
color: #cbd5e1;
}
:root.dark .mobile-editor-status {
background: #334155;
color: #cbd5e1;
}
:root.dark .mobile-editor-status.active,
:root.dark .mobile-editor-tool-button:active,
:root.dark .mobile-editor-tool-button[aria-pressed="true"] {
background: #14532d;
border-color: #166534;
color: #86efac;
}
:root.dark .mobile-editor-status.error,
:root.dark .mobile-editor-error {
background: #4c0519;
border-color: #881337;
color: #fda4af;
}
:root.dark .mobile-editor-notebook-button,
:root.dark .mobile-editor-tags::placeholder,
:root.dark .mobile-editor-title::placeholder {
color: #94a3b8;
}
:root.dark .mobile-editor-sheet-backdrop {
background: rgb(2 6 23 / 0.62);
}
.mobile-editor-notebook-sheet-handle {
width: 38px;
height: 4px;
+15
View File
@@ -2,8 +2,10 @@ import type { Config } from "tailwindcss";
import tailwindAnimate from "tailwindcss-animate";
const brandGreen = (shade: number) => `rgb(var(--brand-green-${shade}-rgb) / <alpha-value>)`;
const slate = (shade: number) => `rgb(var(--slate-${shade}-rgb) / <alpha-value>)`;
export default {
darkMode: "class",
content: {
relative: true,
files: ["./index.html", "./src/**/*.{ts,tsx}"],
@@ -30,6 +32,19 @@ export default {
sm: "calc(var(--radius) - 4px)",
},
colors: {
slate: {
50: slate(50),
100: slate(100),
200: slate(200),
300: slate(300),
400: slate(400),
500: slate(500),
600: slate(600),
700: slate(700),
800: slate(800),
900: slate(900),
950: "#020617",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
+2 -2
View File
@@ -47,8 +47,8 @@ export default defineConfig({
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#f8fafc",
theme_color: "#f8fafc",
background_color: "#0f172a",
theme_color: "#0f172a",
lang: "zh-CN",
categories: ["productivity", "utilities"],
icons: [