Remove next-themes from dashboard

This commit is contained in:
Konstantin Wohlwend 2026-02-26 14:12:17 -08:00
parent 53c1c9e985
commit 9c0d4e058f
9 changed files with 152 additions and 83 deletions

View File

@ -83,7 +83,6 @@
"jose": "^6.1.3",
"lodash": "^4.17.21",
"next": "16.1.5",
"next-themes": "^0.2.1",
"posthog-js": "^1.336.1",
"react": "19.2.3",
"react-day-picker": "^9.6.7",

View File

@ -17,7 +17,7 @@ import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { getSvixResult } from "./utils";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { useTheme } from "next-themes";
import { useTheme } from "@/lib/theme";
import { AppPortal } from "svix-react";
import "svix-react/style.css";

View File

@ -2,7 +2,6 @@ import { DevErrorNotifier } from '@/components/dev-error-notifier';
import { RouterProvider } from '@/components/router';
import { SiteLoadingIndicatorDisplay } from '@/components/site-loading-indicator';
import { StyleLink } from '@/components/style-link';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster, cn } from '@/components/ui';
import { getPublicEnvVar } from '@/lib/env';
import { stackServerApp } from '@/stack';
@ -88,6 +87,9 @@ export default function RootLayout({
...attributes,
});
})}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script dangerouslySetInnerHTML={{ __html: "(function(){try{var t=localStorage.getItem('theme');var d=document.documentElement;var r=t==='dark'||t==='light'?t:window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';d.classList.add(r);d.style.colorScheme=r}catch(e){}})()" }} />
</head>
<body
className={cn(
@ -98,20 +100,18 @@ export default function RootLayout({
>
<Analytics />
<SpeedInsights />
<ThemeProvider>
<StackProvider app={stackServerApp} lang={translationLocale as any}>
<StackTheme>
<ClientPolyfill />
<RouterProvider>
<UserIdentity />
<VersionAlerter />
<BackgroundShine />
{children}
<DevelopmentPortDisplay />
</RouterProvider>
</StackTheme>
</StackProvider>
</ThemeProvider>
<StackProvider app={stackServerApp} lang={translationLocale as any}>
<StackTheme>
<ClientPolyfill />
<RouterProvider>
<UserIdentity />
<VersionAlerter />
<BackgroundShine />
{children}
<DevelopmentPortDisplay />
</RouterProvider>
</StackTheme>
</StackProvider>
<DevErrorNotifier />
<Toaster />
<SiteLoadingIndicatorDisplay />

View File

@ -8,7 +8,7 @@ import { loadConnectAndInitialize } from "@stripe/connect-js";
import {
ConnectComponentsProvider,
} from "@stripe/react-connect-js";
import { useTheme } from "next-themes";
import { useTheme } from "@/lib/theme";
import { useEffect } from "react";
import { appearanceVariablesForTheme } from "./stripe-theme-variables";

View File

@ -3,7 +3,7 @@ import { getPublicEnvVar } from "@/lib/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { useTheme } from "next-themes";
import { useTheme } from "@/lib/theme";
import { useMemo } from "react";
import { appearanceVariablesForTheme } from "./stripe-theme-variables";

View File

@ -1,11 +0,0 @@
'use client';
import { ThemeProvider as NextThemeProvider } from "next-themes";
export function ThemeProvider(props: { children: React.ReactNode }) {
return (
<NextThemeProvider attribute="class">
{props.children}
</NextThemeProvider>
);
}

View File

@ -1,7 +1,7 @@
import { Button } from "@/components/ui";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { useTheme } from "next-themes";
import { useTheme } from "@/lib/theme";
type ViewTransitionWithReady = {
ready: Promise<void>,
@ -14,21 +14,11 @@ type DocumentWithViewTransition = globalThis.Document & {
const TRANSITION_DURATION_MS = 600;
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const isReady = resolvedTheme === "dark" || resolvedTheme === "light";
const { resolvedTheme, setTheme, mounted } = useTheme();
const handleToggle = () => {
if (!isReady) {
return;
}
const nextTheme = resolvedTheme === "dark" ? "light" : "dark";
if (typeof document === "undefined") {
setTheme(nextTheme);
return;
}
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const documentWithTransition: DocumentWithViewTransition = document;
@ -90,7 +80,7 @@ export default function ThemeToggle() {
size="icon"
className="w-8 h-8 hover:bg-muted/50"
onClick={handleToggle}
disabled={!isReady}
disabled={!mounted}
aria-label="Toggle theme"
>
<SunIcon className="hidden dark:block w-4 h-4" />

View File

@ -1,7 +1,7 @@
import Editor, { Monaco } from '@monaco-editor/react';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
import { useTheme } from 'next-themes';
import { useTheme } from '@/lib/theme';
import { dtsBundles } from './dts';
import { useState, useCallback } from 'react';

View File

@ -1,45 +1,136 @@
import { useEffect, useState } from "react";
'use client';
import { useSyncExternalStore } from 'react';
type Theme = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
const STORAGE_KEY = 'theme';
// --- Theme preference store (module-level singleton) ---
let currentTheme: Theme = 'system';
if (typeof window !== 'undefined') {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light' || stored === 'system') {
currentTheme = stored;
}
} catch { /* localStorage unavailable (eg. private browsing) */ }
}
const themeListeners = new Set<() => void>();
function notifyThemeListeners() {
for (const listener of themeListeners) listener();
}
function subscribeTheme(listener: () => void): () => void {
themeListeners.add(listener);
return () => {
themeListeners.delete(listener);
};
}
function getThemeSnapshot(): Theme {
return currentTheme;
}
function getThemeServerSnapshot(): Theme {
return 'system';
}
// --- System theme preference (media query) ---
const darkMq = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)')
: null;
const systemListeners = new Set<() => void>();
if (darkMq) {
darkMq.addEventListener('change', () => {
if (currentTheme === 'system') {
applyThemeToDOM(darkMq.matches ? 'dark' : 'light');
}
for (const listener of systemListeners) listener();
});
}
function subscribeSystem(listener: () => void): () => void {
systemListeners.add(listener);
return () => {
systemListeners.delete(listener);
};
}
function getSystemSnapshot(): ResolvedTheme {
return darkMq?.matches ? 'dark' : 'light';
}
function getSystemServerSnapshot(): ResolvedTheme {
return 'light';
}
// --- Mounted (server=false, client=true; handled via useSyncExternalStore hydration) ---
function subscribeMounted(): () => void {
return () => {};
}
function getMountedClient(): boolean {
return true;
}
function getMountedServer(): boolean {
return false;
}
// --- DOM helpers ---
function applyThemeToDOM(resolved: ResolvedTheme) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolved);
root.style.colorScheme = resolved;
}
function resolve(theme: Theme, system: ResolvedTheme): ResolvedTheme {
return theme === 'system' ? system : theme;
}
// --- Public API ---
export function setTheme(theme: Theme) {
currentTheme = theme;
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch { /* localStorage unavailable */ }
applyThemeToDOM(resolve(theme, getSystemSnapshot()));
notifyThemeListeners();
}
/**
* Returns the current theme of the page.
* Hook that provides the current theme, resolved theme, and a setter.
*
* @returns the current theme and a boolean indicating if the theme has been mounted
*
* Note that this doesn't work in the server component. Please use the css variables for server side rendering.
* Uses `useSyncExternalStore` to subscribe to both the user's preference
* (persisted in localStorage) and the system `prefers-color-scheme` query.
* No `useEffect` required.
*/
export function useTheme() {
const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, getThemeServerSnapshot);
const systemTheme = useSyncExternalStore(subscribeSystem, getSystemSnapshot, getSystemServerSnapshot);
const resolvedTheme = resolve(theme, systemTheme);
const mounted = useSyncExternalStore(subscribeMounted, getMountedClient, getMountedServer);
return { theme, resolvedTheme, systemTheme, setTheme, mounted };
}
/**
* Convenience hook that returns just the resolved theme and mounted state.
* Useful for components that only need to read the current theme.
*/
export function useThemeWatcher() {
const [mounted, setMounted] = useState(false);
const [theme, setTheme] = useState<'dark' | 'light'>('light');
useEffect(() => {
setMounted(true);
const updateTheme = () => {
const themeEl = document.getElementById('--stack-theme-mode');
setTheme(themeEl?.getAttribute('data-stack-theme') === 'dark' ? 'dark' : 'light');
};
// Watch for theme changes
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-stack-theme') {
updateTheme();
break;
}
}
});
const themeEl = document.getElementById('--stack-theme-mode');
if (themeEl) {
updateTheme();
observer.observe(themeEl, {
attributes: true,
attributeFilter: ['data-stack-theme']
});
}
return () => observer.disconnect();
}, []);
return { mounted, theme };
const { resolvedTheme, mounted } = useTheme();
return { theme: resolvedTheme, mounted };
}