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,
|
MissingSeoSetupModal,
|
||||||
SeoApiStatusBanners,
|
SeoApiStatusBanners,
|
||||||
} from "@/client/layout/AppShellParts";
|
} from "@/client/layout/AppShellParts";
|
||||||
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { getProjectNavItems } from "@/client/navigation/items";
|
import { getProjectNavItems } from "@/client/navigation/items";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
@ -208,26 +209,19 @@ function TopNav({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HostedSessionActions />
|
<AccountMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HostedSessionActions mobileOnly />
|
<AccountMenu mobileOnly />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostedSessionActions({
|
function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
||||||
mobileOnly = false,
|
|
||||||
}: {
|
|
||||||
mobileOnly?: boolean;
|
|
||||||
}) {
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
const isHostedMode = isHostedClientAuthMode();
|
||||||
|
const email = session?.user?.email;
|
||||||
if (!isHostedMode || !session?.user?.email) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
const signInHref = getSignInHrefForLocation(window.location);
|
const signInHref = getSignInHrefForLocation(window.location);
|
||||||
@ -255,26 +249,31 @@ function HostedSessionActions({
|
|||||||
tabIndex={0}
|
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"
|
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<li className="menu-title max-w-full">
|
{email ? (
|
||||||
<span className="truncate text-base-content">
|
<li className="menu-title max-w-full">
|
||||||
{session.user.email}
|
<span className="truncate text-base-content">{email}</span>
|
||||||
</span>
|
</li>
|
||||||
</li>
|
) : null}
|
||||||
<li>
|
<ThemePreferenceMenuItems />
|
||||||
<a href={BILLING_ROUTE} className="flex items-center gap-2">
|
{isHostedMode ? (
|
||||||
<CreditCard className="h-4 w-4" />
|
<li>
|
||||||
Billing
|
<a href={BILLING_ROUTE} className="flex items-center gap-2">
|
||||||
</a>
|
<CreditCard className="h-4 w-4" />
|
||||||
</li>
|
Billing
|
||||||
<li>
|
</a>
|
||||||
<button
|
</li>
|
||||||
type="button"
|
) : null}
|
||||||
className="text-error"
|
{isHostedMode && email ? (
|
||||||
onClick={handleSignOut}
|
<li>
|
||||||
>
|
<button
|
||||||
Sign out
|
type="button"
|
||||||
</button>
|
className="text-error"
|
||||||
</li>
|
onClick={handleSignOut}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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 */
|
/* Light theme */
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "todo";
|
name: "openseo";
|
||||||
default: true;
|
default: true;
|
||||||
prefersdark: false;
|
prefersdark: false;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
/* Dark theme - auto-activates via prefers-color-scheme */
|
/* Dark theme - auto-activates via prefers-color-scheme */
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "todo-dark";
|
name: "openseo-dark";
|
||||||
prefersdark: true;
|
prefersdark: true;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--color-base-100: oklch(18% 0 0);
|
--color-base-100: oklch(18% 0 0);
|
||||||
@ -198,59 +198,57 @@ select {
|
|||||||
background-color: #dc2626;
|
background-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
html[data-theme="openseo-dark"] {
|
||||||
:root {
|
--trend-grid-color: oklch(78% 0.01 255 / 0.22);
|
||||||
--trend-grid-color: oklch(78% 0.01 255 / 0.22);
|
--trend-axis-color: oklch(84% 0.01 255 / 0.62);
|
||||||
--trend-axis-color: oklch(84% 0.01 255 / 0.62);
|
--trend-fill-start-opacity: 0.24;
|
||||||
--trend-fill-start-opacity: 0.24;
|
--trend-fill-end-opacity: 0.03;
|
||||||
--trend-fill-end-opacity: 0.03;
|
--trend-tooltip-bg: oklch(21% 0.01 255 / 0.95);
|
||||||
--trend-tooltip-bg: oklch(21% 0.01 255 / 0.95);
|
--trend-tooltip-border: oklch(40% 0.01 255 / 0.65);
|
||||||
--trend-tooltip-border: oklch(40% 0.01 255 / 0.65);
|
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
|
||||||
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-badge {
|
||||||
.score-badge {
|
color: oklch(96% 0.01 95);
|
||||||
color: oklch(96% 0.01 95);
|
filter: saturate(1.26) brightness(1.14);
|
||||||
filter: saturate(1.26) brightness(1.14);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-1 {
|
||||||
.score-tier-1 {
|
background-color: oklch(58% 0.12 148);
|
||||||
background-color: oklch(58% 0.12 148);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-2 {
|
||||||
.score-tier-2 {
|
background-color: oklch(60% 0.115 132);
|
||||||
background-color: oklch(60% 0.115 132);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-3 {
|
||||||
.score-tier-3 {
|
background-color: oklch(66% 0.11 92);
|
||||||
background-color: oklch(66% 0.11 92);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-4 {
|
||||||
.score-tier-4 {
|
background-color: oklch(64% 0.12 56);
|
||||||
background-color: oklch(64% 0.12 56);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-5 {
|
||||||
.score-tier-5 {
|
background-color: oklch(62% 0.12 36);
|
||||||
background-color: oklch(62% 0.12 36);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-6 {
|
||||||
.score-tier-6 {
|
background-color: oklch(58% 0.11 30);
|
||||||
background-color: oklch(58% 0.11 30);
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .alert-warning {
|
||||||
.alert-warning {
|
@apply border-warning/60 bg-warning/20;
|
||||||
@apply border-warning/60 bg-warning/20;
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .alert-info {
|
||||||
.alert-info {
|
@apply border-info/60 bg-info/20;
|
||||||
@apply border-info/60 bg-info/20;
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .alert-success {
|
||||||
.alert-success {
|
@apply border-success/60 bg-success/20;
|
||||||
@apply border-success/60 bg-success/20;
|
}
|
||||||
}
|
|
||||||
|
html[data-theme="openseo-dark"] .alert-error {
|
||||||
.alert-error {
|
@apply border-error/60 bg-error/20;
|
||||||
@apply border-error/60 bg-error/20;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
||||||
|
import { themePreferenceInitScript } from "@/client/lib/theme";
|
||||||
import { initPostHog } from "@/client/lib/posthog";
|
import { initPostHog } from "@/client/lib/posthog";
|
||||||
import { NotFound } from "@/client/components/NotFound";
|
import { NotFound } from "@/client/components/NotFound";
|
||||||
import appCss from "@/client/styles/app.css?url";
|
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";
|
import.meta.env.DEV && import.meta.env.VITE_SHOW_DEVTOOLS !== "false";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html>
|
<html suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{ __html: themePreferenceInitScript }}
|
||||||
|
/>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|||||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
|
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
@ -164,11 +165,7 @@ function SubscribePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubscribePageAccountMenu({
|
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
||||||
email,
|
|
||||||
}: {
|
|
||||||
email: string | undefined;
|
|
||||||
}) {
|
|
||||||
if (!email) return null;
|
if (!email) return null;
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
@ -200,6 +197,7 @@ function SubscribePageAccountMenu({
|
|||||||
<li className="menu-title max-w-full">
|
<li className="menu-title max-w-full">
|
||||||
<span className="truncate text-base-content">{email}</span>
|
<span className="truncate text-base-content">{email}</span>
|
||||||
</li>
|
</li>
|
||||||
|
<ThemePreferenceMenuItems />
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user