feat(07-01): extend ThemeContext to support three named themes with THEME_META export
- Expand Theme type to catppuccin-mocha | tokyo-night | catppuccin-latte - Export THEME_META with label, dark boolean, bg hex, primary hex per theme - applyTheme toggles .dark and .theme-tokyo-night classes correctly - toggleTheme cycles all three themes (Mocha -> Tokyo Night -> Latte -> Mocha) - readStoredTheme falls back to catppuccin-mocha for old localStorage values - Fix Layout.tsx: replace theme === 'dark' comparison with THEME_META[theme].dark - Fix MarkdownBody.tsx: replace theme === 'dark' comparisons with THEME_META[theme].dark
This commit is contained in:
parent
f0f65a63dd
commit
560400b187
3 changed files with 44 additions and 24 deletions
|
|
@ -20,7 +20,7 @@ import { useDialog } from "../context/DialogContext";
|
|||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||
import { healthApi } from "../api/health";
|
||||
|
|
@ -67,7 +67,7 @@ export function Layout() {
|
|||
const lastMainScrollTop = useRef(0);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const isDarkTheme = THEME_META[theme].dark;
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
|
|
@ -331,10 +331,10 @@ export function Layout() {
|
|||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
aria-label="Cycle theme"
|
||||
title="Cycle theme"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -389,10 +389,10 @@ export function Layout() {
|
|||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${nextTheme} mode`}
|
||||
title={`Switch to ${nextTheme} mode`}
|
||||
aria-label="Cycle theme"
|
||||
title="Cycle theme"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "reac
|
|||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
|
|
@ -97,7 +97,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
|||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||
const mermaidSource = extractMermaidSource(preChildren);
|
||||
if (mermaidSource) {
|
||||
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
||||
return <MermaidDiagramBlock source={mermaidSource} darkMode={THEME_META[theme].dark} />;
|
||||
}
|
||||
return <pre {...preProps}>{preChildren}</pre>;
|
||||
},
|
||||
|
|
@ -140,7 +140,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
|||
<div
|
||||
className={cn(
|
||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
||||
theme === "dark" && "prose-invert",
|
||||
THEME_META[theme].dark && "prose-invert",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,16 @@ import {
|
|||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
|
||||
|
||||
export const THEME_META: Record<Theme, { label: string; dark: boolean; bg: string; primary: string }> = {
|
||||
"catppuccin-mocha": { label: "Catppuccin Mocha", dark: true, bg: "#1e1e2e", primary: "#89b4fa" },
|
||||
"tokyo-night": { label: "Tokyo Night", dark: true, bg: "#1a1b26", primary: "#7aa2f7" },
|
||||
"catppuccin-latte": { label: "Catppuccin Latte", dark: false, bg: "#eff1f5", primary: "#1e66f5" },
|
||||
};
|
||||
|
||||
const VALID_THEMES: Theme[] = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"];
|
||||
const DEFAULT_THEME: Theme = "catppuccin-mocha";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
|
|
@ -17,36 +26,47 @@ interface ThemeContextValue {
|
|||
}
|
||||
|
||||
const THEME_STORAGE_KEY = "paperclip.theme";
|
||||
const DARK_THEME_COLOR = "#18181b";
|
||||
const LIGHT_THEME_COLOR = "#ffffff";
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function resolveThemeFromDocument(): Theme {
|
||||
if (typeof document === "undefined") return "dark";
|
||||
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
function isValidTheme(value: string | null): value is Theme {
|
||||
return value !== null && VALID_THEMES.includes(value as Theme);
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
if (typeof document === "undefined") return DEFAULT_THEME;
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const isDark = theme === "dark";
|
||||
const meta = THEME_META[theme];
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", isDark);
|
||||
root.style.colorScheme = isDark ? "dark" : "light";
|
||||
root.classList.toggle("dark", meta.dark);
|
||||
root.classList.toggle("theme-tokyo-night", theme === "tokyo-night");
|
||||
root.style.colorScheme = meta.dark ? "dark" : "light";
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta instanceof HTMLMetaElement) {
|
||||
themeColorMeta.setAttribute("content", isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
|
||||
themeColorMeta.setAttribute("content", meta.bg);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => resolveThemeFromDocument());
|
||||
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
|
||||
|
||||
const setTheme = useCallback((nextTheme: Theme) => {
|
||||
setThemeState(nextTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((current) => (current === "dark" ? "light" : "dark"));
|
||||
setThemeState((current) => {
|
||||
const idx = VALID_THEMES.indexOf(current);
|
||||
return VALID_THEMES[(idx + 1) % VALID_THEMES.length];
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,7 +74,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Ignore local storage write failures in restricted environments.
|
||||
// Ignore localStorage write failures in restricted environments.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue