Archived
Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.
Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,181 +8,53 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
fallbackAppearance,
|
||||
isColorMode,
|
||||
isColorTheme,
|
||||
isFontPreference,
|
||||
isHslChannels,
|
||||
isInterfaceTheme,
|
||||
isPdfTemplate,
|
||||
isRadiusPreference,
|
||||
isSidebarStyle,
|
||||
type PdfTemplate,
|
||||
} from "~/lib/appearance";
|
||||
import {
|
||||
defaultBodyFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
defaultSidebarStyle,
|
||||
brand as defaultBrand,
|
||||
type ColorMode,
|
||||
type ColorTheme,
|
||||
type FontPreference,
|
||||
type InterfaceTheme,
|
||||
type RadiusPreference,
|
||||
type SidebarStyle,
|
||||
} from "~/lib/branding";
|
||||
import { defaultColorMode, isColorMode, type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type AppearancePreferences = {
|
||||
interfaceTheme: InterfaceTheme;
|
||||
bodyFontPreference: FontPreference;
|
||||
headingFontPreference: FontPreference;
|
||||
radiusPreference: RadiusPreference;
|
||||
sidebarStyle: SidebarStyle;
|
||||
colorMode: ColorMode;
|
||||
colorTheme: ColorTheme;
|
||||
customColor?: string;
|
||||
brandName: string;
|
||||
brandTagline: string;
|
||||
brandLogoText: string;
|
||||
brandIcon: string;
|
||||
pdfTemplate: PdfTemplate;
|
||||
pdfAccentColor: string;
|
||||
pdfFooterText: string;
|
||||
pdfShowLogo: boolean;
|
||||
pdfShowPageNumbers: boolean;
|
||||
};
|
||||
|
||||
export type AppearancePatch = Partial<AppearancePreferences>;
|
||||
|
||||
export type AppearanceContextValue = AppearancePreferences & {
|
||||
updateAppearance: (patch: AppearancePatch) => void;
|
||||
updateAppearanceDebounced: (patch: AppearancePatch) => void;
|
||||
isUpdating: boolean;
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "bv.appearance";
|
||||
|
||||
export const defaultAppearance: AppearancePreferences = {
|
||||
interfaceTheme: defaultInterfaceTheme,
|
||||
bodyFontPreference: defaultBodyFontPreference,
|
||||
headingFontPreference: defaultHeadingFontPreference,
|
||||
radiusPreference: defaultRadiusPreference,
|
||||
sidebarStyle: defaultSidebarStyle,
|
||||
colorMode: fallbackAppearance.colorMode,
|
||||
colorTheme: fallbackAppearance.colorTheme,
|
||||
brandName: defaultBrand.name,
|
||||
brandTagline: defaultBrand.tagline,
|
||||
brandLogoText: defaultBrand.logoText,
|
||||
brandIcon: defaultBrand.icon,
|
||||
pdfTemplate: fallbackAppearance.pdfTemplate,
|
||||
pdfAccentColor: fallbackAppearance.pdfAccentColor,
|
||||
pdfFooterText: fallbackAppearance.pdfFooterText,
|
||||
pdfShowLogo: fallbackAppearance.pdfShowLogo,
|
||||
pdfShowPageNumbers: fallbackAppearance.pdfShowPageNumbers,
|
||||
colorMode: defaultColorMode,
|
||||
};
|
||||
|
||||
export const AppearanceContext =
|
||||
createContext<AppearanceContextValue | null>(null);
|
||||
|
||||
export function readStoredAppearance(): Partial<AppearancePreferences> | null {
|
||||
export function readStoredColorMode(): ColorMode | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
interfaceTheme: isInterfaceTheme(parsed.interfaceTheme)
|
||||
? parsed.interfaceTheme
|
||||
: undefined,
|
||||
bodyFontPreference: isFontPreference(parsed.bodyFontPreference)
|
||||
? parsed.bodyFontPreference
|
||||
: isFontPreference(parsed.fontPreference)
|
||||
? parsed.fontPreference
|
||||
: undefined,
|
||||
headingFontPreference: isFontPreference(parsed.headingFontPreference)
|
||||
? parsed.headingFontPreference
|
||||
: isFontPreference(parsed.fontPreference)
|
||||
? parsed.fontPreference
|
||||
: undefined,
|
||||
radiusPreference: isRadiusPreference(parsed.radiusPreference)
|
||||
? parsed.radiusPreference
|
||||
: undefined,
|
||||
sidebarStyle: isSidebarStyle(parsed.sidebarStyle)
|
||||
? parsed.sidebarStyle
|
||||
: undefined,
|
||||
colorMode: isColorMode(parsed.colorMode) ? parsed.colorMode : undefined,
|
||||
colorTheme: isColorTheme(parsed.colorTheme)
|
||||
? parsed.colorTheme
|
||||
: undefined,
|
||||
customColor: isHslChannels(parsed.customColor)
|
||||
? parsed.customColor
|
||||
: undefined,
|
||||
brandName:
|
||||
typeof parsed.brandName === "string" ? parsed.brandName : undefined,
|
||||
brandTagline:
|
||||
typeof parsed.brandTagline === "string"
|
||||
? parsed.brandTagline
|
||||
: undefined,
|
||||
brandLogoText:
|
||||
typeof parsed.brandLogoText === "string"
|
||||
? parsed.brandLogoText
|
||||
: undefined,
|
||||
brandIcon:
|
||||
typeof parsed.brandIcon === "string" ? parsed.brandIcon : undefined,
|
||||
pdfTemplate: isPdfTemplate(parsed.pdfTemplate)
|
||||
? parsed.pdfTemplate
|
||||
: undefined,
|
||||
pdfAccentColor:
|
||||
typeof parsed.pdfAccentColor === "string"
|
||||
? parsed.pdfAccentColor
|
||||
: undefined,
|
||||
pdfFooterText:
|
||||
typeof parsed.pdfFooterText === "string"
|
||||
? parsed.pdfFooterText
|
||||
: undefined,
|
||||
pdfShowLogo:
|
||||
typeof parsed.pdfShowLogo === "boolean"
|
||||
? parsed.pdfShowLogo
|
||||
: undefined,
|
||||
pdfShowPageNumbers:
|
||||
typeof parsed.pdfShowPageNumbers === "boolean"
|
||||
? parsed.pdfShowPageNumbers
|
||||
: undefined,
|
||||
};
|
||||
const parsed = JSON.parse(raw) as { colorMode?: unknown };
|
||||
return isColorMode(parsed.colorMode) ? parsed.colorMode : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredAppearance(prefs: AppearancePreferences) {
|
||||
export function writeStoredColorMode(colorMode: ColorMode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ colorMode }));
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or locked-down contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAppearance(prefs: AppearancePreferences) {
|
||||
export function applyColorMode(colorMode: ColorMode) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.dataset.interfaceTheme = prefs.interfaceTheme;
|
||||
root.dataset.bodyFont = prefs.bodyFontPreference;
|
||||
root.dataset.headingFont = prefs.headingFontPreference;
|
||||
root.dataset.radius = prefs.radiusPreference;
|
||||
root.dataset.sidebarStyle = prefs.sidebarStyle;
|
||||
root.dataset.colorMode = prefs.colorMode;
|
||||
root.dataset.colorTheme = prefs.colorTheme;
|
||||
|
||||
root.classList.toggle("dark", prefs.colorMode === "dark");
|
||||
|
||||
if (prefs.customColor) {
|
||||
root.style.setProperty("--custom-primary", prefs.customColor);
|
||||
} else {
|
||||
root.style.removeProperty("--custom-primary");
|
||||
}
|
||||
root.dataset.colorMode = colorMode;
|
||||
root.classList.toggle("dark", colorMode === "dark");
|
||||
}
|
||||
|
||||
/** Local-only appearance provider for marketing and auth pages (no tRPC). */
|
||||
@@ -191,48 +63,36 @@ export function AppearanceProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [appearance, setAppearance] =
|
||||
useState<AppearancePreferences>(defaultAppearance);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
|
||||
|
||||
useEffect(() => {
|
||||
const storedAppearance = readStoredAppearance();
|
||||
if (!storedAppearance) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAppearance((prev) => ({ ...prev, ...storedAppearance }));
|
||||
const stored = readStoredColorMode();
|
||||
if (stored) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setColorMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyAppearance(appearance);
|
||||
writeStoredAppearance(appearance);
|
||||
}, [appearance]);
|
||||
applyColorMode(colorMode);
|
||||
writeStoredColorMode(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
const updateAppearance = useCallback((patch: AppearancePatch) => {
|
||||
setAppearance((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
applyAppearance(next);
|
||||
writeStoredAppearance(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateAppearanceDebounced = useCallback((patch: AppearancePatch) => {
|
||||
setAppearance((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
applyAppearance(next);
|
||||
writeStoredAppearance(next);
|
||||
return next;
|
||||
});
|
||||
if (patch.colorMode) {
|
||||
setColorMode(patch.colorMode);
|
||||
applyColorMode(patch.colorMode);
|
||||
writeStoredColorMode(patch.colorMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AppearanceContextValue>(
|
||||
() => ({
|
||||
...appearance,
|
||||
colorMode,
|
||||
updateAppearance,
|
||||
updateAppearanceDebounced,
|
||||
isUpdating: false,
|
||||
}),
|
||||
[appearance, updateAppearance, updateAppearanceDebounced],
|
||||
[colorMode, updateAppearance],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user