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 { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||||
import { healthApi } from "../api/health";
|
import { healthApi } from "../api/health";
|
||||||
|
|
@ -67,7 +67,7 @@ export function Layout() {
|
||||||
const lastMainScrollTop = useRef(0);
|
const lastMainScrollTop = useRef(0);
|
||||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
const isDarkTheme = THEME_META[theme].dark;
|
||||||
const matchedCompany = useMemo(() => {
|
const matchedCompany = useMemo(() => {
|
||||||
if (!companyPrefix) return null;
|
if (!companyPrefix) return null;
|
||||||
const requestedPrefix = companyPrefix.toUpperCase();
|
const requestedPrefix = companyPrefix.toUpperCase();
|
||||||
|
|
@ -331,10 +331,10 @@ export function Layout() {
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-muted-foreground shrink-0"
|
className="text-muted-foreground shrink-0"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label={`Switch to ${nextTheme} mode`}
|
aria-label="Cycle theme"
|
||||||
title={`Switch to ${nextTheme} mode`}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,10 +389,10 @@ export function Layout() {
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-muted-foreground shrink-0"
|
className="text-muted-foreground shrink-0"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
aria-label={`Switch to ${nextTheme} mode`}
|
aria-label="Cycle theme"
|
||||||
title={`Switch to ${nextTheme} mode`}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "reac
|
||||||
import Markdown, { type Components } from "react-markdown";
|
import Markdown, { type Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useTheme } from "../context/ThemeContext";
|
import { useTheme, THEME_META } from "../context/ThemeContext";
|
||||||
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
||||||
|
|
||||||
interface MarkdownBodyProps {
|
interface MarkdownBodyProps {
|
||||||
|
|
@ -97,7 +97,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
||||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
const mermaidSource = extractMermaidSource(preChildren);
|
const mermaidSource = extractMermaidSource(preChildren);
|
||||||
if (mermaidSource) {
|
if (mermaidSource) {
|
||||||
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
return <MermaidDiagramBlock source={mermaidSource} darkMode={THEME_META[theme].dark} />;
|
||||||
}
|
}
|
||||||
return <pre {...preProps}>{preChildren}</pre>;
|
return <pre {...preProps}>{preChildren}</pre>;
|
||||||
},
|
},
|
||||||
|
|
@ -140,7 +140,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
||||||
theme === "dark" && "prose-invert",
|
THEME_META[theme].dark && "prose-invert",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,16 @@ import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} 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 {
|
interface ThemeContextValue {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
|
@ -17,36 +26,47 @@ interface ThemeContextValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = "paperclip.theme";
|
const THEME_STORAGE_KEY = "paperclip.theme";
|
||||||
const DARK_THEME_COLOR = "#18181b";
|
|
||||||
const LIGHT_THEME_COLOR = "#ffffff";
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
function resolveThemeFromDocument(): Theme {
|
function isValidTheme(value: string | null): value is Theme {
|
||||||
if (typeof document === "undefined") return "dark";
|
return value !== null && VALID_THEMES.includes(value as Theme);
|
||||||
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
}
|
||||||
|
|
||||||
|
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) {
|
function applyTheme(theme: Theme) {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const isDark = theme === "dark";
|
const meta = THEME_META[theme];
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.toggle("dark", isDark);
|
root.classList.toggle("dark", meta.dark);
|
||||||
root.style.colorScheme = isDark ? "dark" : "light";
|
root.classList.toggle("theme-tokyo-night", theme === "tokyo-night");
|
||||||
|
root.style.colorScheme = meta.dark ? "dark" : "light";
|
||||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (themeColorMeta instanceof HTMLMetaElement) {
|
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 }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<Theme>(() => resolveThemeFromDocument());
|
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
|
||||||
|
|
||||||
const setTheme = useCallback((nextTheme: Theme) => {
|
const setTheme = useCallback((nextTheme: Theme) => {
|
||||||
setThemeState(nextTheme);
|
setThemeState(nextTheme);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -54,7 +74,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local storage write failures in restricted environments.
|
// Ignore localStorage write failures in restricted environments.
|
||||||
}
|
}
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue