feat: add theme switcher with system/light/dark segmented control (#75)
* feat: add theme switcher with system/light/dark segmented control Add a three-way theme toggle (system, light, dark) as a segmented icon control in the account dropdown menu. Rename placeholder daisyUI theme names from "todo" to "openseo". * refactor: always resolve data-theme explicitly, remove duplicated dark CSS Instead of removing data-theme in "system" mode and relying on @media (prefers-color-scheme: dark) CSS, always resolve to an explicit theme name via matchMedia. This eliminates the duplicated dark-mode CSS overrides.
This commit is contained in:
parent
bd10955a8d
commit
3081bc624a
60
src/client/components/ThemePreferenceMenuItems.tsx
Normal file
60
src/client/components/ThemePreferenceMenuItems.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: ThemePreference;
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
];
|
||||
|
||||
export function ThemePreferenceMenuItems() {
|
||||
const { themePreference, setThemePreference } = useThemePreference();
|
||||
|
||||
return (
|
||||
<>
|
||||
<li className="menu-title pt-2">
|
||||
<span>Theme</span>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Theme preference"
|
||||
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const isActive = option.value === themePreference;
|
||||
const Icon = option.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className="tooltip tooltip-bottom flex flex-1 before:whitespace-nowrap"
|
||||
data-tip={option.label}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={option.label}
|
||||
className={`flex flex-1 items-center justify-center rounded-md px-2.5 py-1.5 transition-colors ${
|
||||
isActive
|
||||
? "bg-base-100 text-base-content shadow-sm"
|
||||
: "text-base-content/50 hover:text-base-content/80"
|
||||
}`}
|
||||
onClick={() => setThemePreference(option.value)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
MissingSeoSetupModal,
|
||||
SeoApiStatusBanners,
|
||||
} from "@/client/layout/AppShellParts";
|
||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||
import { getProjectNavItems } from "@/client/navigation/items";
|
||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
@ -208,26 +209,19 @@ function TopNav({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<HostedSessionActions />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HostedSessionActions mobileOnly />
|
||||
<AccountMenu mobileOnly />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostedSessionActions({
|
||||
mobileOnly = false,
|
||||
}: {
|
||||
mobileOnly?: boolean;
|
||||
}) {
|
||||
function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
||||
const { data: session } = useSession();
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
|
||||
if (!isHostedMode || !session?.user?.email) {
|
||||
return null;
|
||||
}
|
||||
const email = session?.user?.email;
|
||||
|
||||
const handleSignOut = () => {
|
||||
const signInHref = getSignInHrefForLocation(window.location);
|
||||
@ -255,17 +249,21 @@ function HostedSessionActions({
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
{email ? (
|
||||
<li className="menu-title max-w-full">
|
||||
<span className="truncate text-base-content">
|
||||
{session.user.email}
|
||||
</span>
|
||||
<span className="truncate text-base-content">{email}</span>
|
||||
</li>
|
||||
) : null}
|
||||
<ThemePreferenceMenuItems />
|
||||
{isHostedMode ? (
|
||||
<li>
|
||||
<a href={BILLING_ROUTE} className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Billing
|
||||
</a>
|
||||
</li>
|
||||
) : null}
|
||||
{isHostedMode && email ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@ -275,6 +273,7 @@ function HostedSessionActions({
|
||||
Sign out
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
133
src/client/lib/theme.ts
Normal file
133
src/client/lib/theme.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const LIGHT_THEME_NAME = "openseo";
|
||||
const DARK_THEME_NAME = "openseo-dark";
|
||||
|
||||
const THEME_STORAGE_KEY = "theme-preference";
|
||||
const THEME_CHANGE_EVENT = "theme-preference-change";
|
||||
|
||||
function readThemePreference(): ThemePreference {
|
||||
if (typeof window === "undefined") {
|
||||
return "system";
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
function writeThemePreference(themePreference: ThemePreference) {
|
||||
try {
|
||||
if (themePreference === "system") {
|
||||
window.localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
} else {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||
}
|
||||
} catch {
|
||||
// localStorage can be unavailable in private browsing or strict browser modes.
|
||||
}
|
||||
}
|
||||
|
||||
function resolveThemeName(themePreference: ThemePreference): string {
|
||||
if (themePreference === "light") return LIGHT_THEME_NAME;
|
||||
if (themePreference === "dark") return DARK_THEME_NAME;
|
||||
|
||||
// "system" — resolve from OS preference
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
return DARK_THEME_NAME;
|
||||
}
|
||||
return LIGHT_THEME_NAME;
|
||||
}
|
||||
|
||||
function applyThemePreference(themePreference: ThemePreference) {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
resolveThemeName(themePreference),
|
||||
);
|
||||
}
|
||||
|
||||
function subscribeToThemePreference(onStoreChange: () => void) {
|
||||
if (typeof window === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handleThemeChange = () => {
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key && event.key !== THEME_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleMediaChange = () => {
|
||||
// Re-apply when OS preference changes so "system" mode stays in sync
|
||||
applyThemePreference(readThemePreference());
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
mediaQuery.addEventListener("change", handleMediaChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
mediaQuery.removeEventListener("change", handleMediaChange);
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemePreference() {
|
||||
const themePreference = React.useSyncExternalStore<ThemePreference>(
|
||||
subscribeToThemePreference,
|
||||
readThemePreference,
|
||||
() => "system",
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
applyThemePreference(themePreference);
|
||||
}, [themePreference]);
|
||||
|
||||
const setThemePreference = React.useCallback(
|
||||
(nextThemePreference: ThemePreference) => {
|
||||
writeThemePreference(nextThemePreference);
|
||||
applyThemePreference(nextThemePreference);
|
||||
window.dispatchEvent(new Event(THEME_CHANGE_EVENT));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { themePreference, setThemePreference };
|
||||
}
|
||||
|
||||
export const themePreferenceInitScript = `(() => {
|
||||
try {
|
||||
var p = window.localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
||||
var t;
|
||||
if (p === "light") t = ${JSON.stringify(LIGHT_THEME_NAME)};
|
||||
else if (p === "dark") t = ${JSON.stringify(DARK_THEME_NAME)};
|
||||
else t = window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(DARK_THEME_NAME)} : ${JSON.stringify(LIGHT_THEME_NAME)};
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
} catch {
|
||||
document.documentElement.setAttribute("data-theme", ${JSON.stringify(LIGHT_THEME_NAME)});
|
||||
}
|
||||
})();`;
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
/* Light theme */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "todo";
|
||||
name: "openseo";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: light;
|
||||
@ -58,7 +58,7 @@
|
||||
|
||||
/* Dark theme - auto-activates via prefers-color-scheme */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "todo-dark";
|
||||
name: "openseo-dark";
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
--color-base-100: oklch(18% 0 0);
|
||||
@ -198,8 +198,7 @@ select {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
html[data-theme="openseo-dark"] {
|
||||
--trend-grid-color: oklch(78% 0.01 255 / 0.22);
|
||||
--trend-axis-color: oklch(84% 0.01 255 / 0.62);
|
||||
--trend-fill-start-opacity: 0.24;
|
||||
@ -209,48 +208,47 @@ select {
|
||||
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
html[data-theme="openseo-dark"] .score-badge {
|
||||
color: oklch(96% 0.01 95);
|
||||
filter: saturate(1.26) brightness(1.14);
|
||||
}
|
||||
|
||||
.score-tier-1 {
|
||||
html[data-theme="openseo-dark"] .score-tier-1 {
|
||||
background-color: oklch(58% 0.12 148);
|
||||
}
|
||||
|
||||
.score-tier-2 {
|
||||
html[data-theme="openseo-dark"] .score-tier-2 {
|
||||
background-color: oklch(60% 0.115 132);
|
||||
}
|
||||
|
||||
.score-tier-3 {
|
||||
html[data-theme="openseo-dark"] .score-tier-3 {
|
||||
background-color: oklch(66% 0.11 92);
|
||||
}
|
||||
|
||||
.score-tier-4 {
|
||||
html[data-theme="openseo-dark"] .score-tier-4 {
|
||||
background-color: oklch(64% 0.12 56);
|
||||
}
|
||||
|
||||
.score-tier-5 {
|
||||
html[data-theme="openseo-dark"] .score-tier-5 {
|
||||
background-color: oklch(62% 0.12 36);
|
||||
}
|
||||
|
||||
.score-tier-6 {
|
||||
html[data-theme="openseo-dark"] .score-tier-6 {
|
||||
background-color: oklch(58% 0.11 30);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
html[data-theme="openseo-dark"] .alert-warning {
|
||||
@apply border-warning/60 bg-warning/20;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
html[data-theme="openseo-dark"] .alert-info {
|
||||
@apply border-info/60 bg-info/20;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
html[data-theme="openseo-dark"] .alert-success {
|
||||
@apply border-success/60 bg-success/20;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
html[data-theme="openseo-dark"] .alert-error {
|
||||
@apply border-error/60 bg-error/20;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
||||
import { themePreferenceInitScript } from "@/client/lib/theme";
|
||||
import { initPostHog } from "@/client/lib/posthog";
|
||||
import { NotFound } from "@/client/components/NotFound";
|
||||
import appCss from "@/client/styles/app.css?url";
|
||||
@ -93,8 +94,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
import.meta.env.DEV && import.meta.env.VITE_SHOW_DEVTOOLS !== "false";
|
||||
|
||||
return (
|
||||
<html>
|
||||
<html suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themePreferenceInitScript }}
|
||||
/>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { User } from "lucide-react";
|
||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
@ -164,11 +165,7 @@ function SubscribePageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function SubscribePageAccountMenu({
|
||||
email,
|
||||
}: {
|
||||
email: string | undefined;
|
||||
}) {
|
||||
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
||||
if (!email) return null;
|
||||
|
||||
const handleSignOut = () => {
|
||||
@ -200,6 +197,7 @@ function SubscribePageAccountMenu({
|
||||
<li className="menu-title max-w-full">
|
||||
<span className="truncate text-base-content">{email}</span>
|
||||
</li>
|
||||
<ThemePreferenceMenuItems />
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user