diff --git a/src/components/theme-customizer.tsx b/src/components/theme-customizer.tsx new file mode 100644 index 00000000..e6206cbb --- /dev/null +++ b/src/components/theme-customizer.tsx @@ -0,0 +1,65 @@ +import React from "react" +import { Button } from "./button" +import { Dialog } from "./dialog" +import { Tooltip } from "./tooltip" +import { ThemeColor, THEME_COLORS_MAP, getCurrentTheme, applyTheme, useSaveTheme } from "../hooks/theme" + +export function ThemeCustomizer() { + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [currentTheme, setCurrentTheme] = React.useState(() => getCurrentTheme()) + const saveTheme = useSaveTheme() + + const handleThemeChange = (color: ThemeColor) => { + setCurrentTheme(color) + applyTheme(color) + } + + const handleSave = async () => { + await saveTheme(currentTheme) + setIsDialogOpen(false) + } + + return ( + + + + + +
+
+ +
+ {Object.entries(THEME_COLORS_MAP).map(([color, value]) => ( + + + + + {color} + + ))} +
+
+
+ + +
+
+
+
+ ) +} diff --git a/src/hooks/theme.ts b/src/hooks/theme.ts new file mode 100644 index 00000000..6271b2aa --- /dev/null +++ b/src/hooks/theme.ts @@ -0,0 +1,139 @@ +import { useAtomValue, useSetAtom } from "jotai" +import React from "react" +import { globalStateMachineAtom, isRepoClonedAtom } from "../global-state" +import { fs } from "../utils/fs" +import { REPO_DIR } from "../utils/git" +import path from "path" + +const THEME_COLORS = { + sand: "#dad9d6", + cyan: "#9ddde7", + yellow: "#f3d768", + amber: "#f3d673", + green: "#adddc0", + red: "#fdbdbe", + purple: "#e0c4f4", + orange: "#ffc182", +} as const + +export type ThemeColor = keyof typeof THEME_COLORS +export const THEME_COLORS_MAP = THEME_COLORS + +export function getCurrentTheme(): ThemeColor { + // Try to load saved theme from CSS variables + const style = getComputedStyle(document.documentElement) + const neutral6 = style.getPropertyValue("--neutral-6").trim() + + // Find which theme matches the current neutral-6 value + const savedTheme = Object.entries(THEME_COLORS).find(([_, value]) => { + return neutral6 === value || neutral6 === `var(--${value}-6)` + })?.[0] as ThemeColor + + return savedTheme || "sand" +} + +export function applyTheme(color: ThemeColor) { + const cssVars = {} as Record + + // Update neutral scale variables + for (let i = 1; i <= 12; i++) { + const neutralVar = `--neutral-${i}` + const newColorVar = `--${color}-${i}` + cssVars[neutralVar] = `var(${newColorVar})` + } + + // Update alpha variables + for (let i = 1; i <= 12; i++) { + const neutralVar = `--neutral-a${i}` + const newColorVar = `--${color}-a${i}` + cssVars[neutralVar] = `var(${newColorVar})` + } + + // Apply colors immediately + Object.entries(cssVars).forEach(([variable, value]) => { + document.documentElement.style.setProperty(variable, value) + }) + + return cssVars +} + +export function generateThemeCSS(cssVars: Record) { + return `:root { +${Object.entries(cssVars) + .map(([variable, value]) => ` ${variable}: ${value};`) + .join("\n")} +} +` +} + +export function useLoadTheme() { + const isRepoCloned = useAtomValue(isRepoClonedAtom) + + React.useEffect(() => { + if (!isRepoCloned) return + + async function loadTheme() { + try { + // Try to read theme.css from the .lumen directory + const content = await fs.promises.readFile(`${REPO_DIR}/.lumen/theme.css`, "utf8") + + // Create a style element + const style = document.createElement("style") + style.setAttribute("id", "custom-theme") + style.textContent = content.toString() + + // Remove any existing custom theme + document.getElementById("custom-theme")?.remove() + + // Add the new style element + document.head.appendChild(style) + } catch (error) { + // If theme.css doesn't exist, apply the default theme + console.debug("No custom theme found:", error) + applyTheme("sand") + } + } + + loadTheme() + }, [isRepoCloned]) +} + +export function useSaveTheme() { + const send = useSetAtom(globalStateMachineAtom) + + return React.useCallback( + async (color: ThemeColor) => { + const cssVars = applyTheme(color) + const cssContent = generateThemeCSS(cssVars) + + // First ensure the .lumen directory exists + try { + const lumenDir = path.join(REPO_DIR, ".lumen") + try { + await fs.promises.stat(lumenDir) + } catch { + await fs.promises.mkdir(lumenDir) + } + } catch (error) { + console.debug("Error creating .lumen directory:", error) + } + + // Save theme.css to the .lumen directory using the global state machine + send({ + type: "WRITE_FILES", + markdownFiles: { ".lumen/theme.css": cssContent }, + commitMessage: `Update theme colors to ${color}`, + }) + + // Also update the style element + const style = document.getElementById("custom-theme") || document.createElement("style") + style.setAttribute("id", "custom-theme") + style.textContent = cssContent + + if (!style.parentElement) { + document.head.appendChild(style) + } + }, + [send], + ) +} diff --git a/src/routes/_appRoot.settings.tsx b/src/routes/_appRoot.settings.tsx index 90b9e58d..dce60279 100644 --- a/src/routes/_appRoot.settings.tsx +++ b/src/routes/_appRoot.settings.tsx @@ -24,6 +24,7 @@ import { } from "../global-state" import { useEditorSettings } from "../hooks/editor-settings" import { cx } from "../utils/cx" +import { ThemeCustomizer } from "../components/theme-customizer" export const Route = createFileRoute("/_appRoot/settings")({ component: RouteComponent, @@ -158,27 +159,32 @@ function ThemeSection() { return ( - setTheme(value as "default" | "eink")} - className="flex flex-col gap-3 coarse:gap-4" - name="theme" - > -
- - -
-
- - -
-
+
+ setTheme(value as "default" | "eink")} + className="flex flex-col gap-3 coarse:gap-4" + name="theme" + > +
+ + +
+
+ + +
+
+ +
+ +
) } diff --git a/src/routes/_appRoot.tsx b/src/routes/_appRoot.tsx index 918d13ee..30082767 100644 --- a/src/routes/_appRoot.tsx +++ b/src/routes/_appRoot.tsx @@ -23,6 +23,7 @@ import { } from "../global-state" import { useSearchNotes } from "../hooks/search" import { useValueRef } from "../hooks/value-ref" +import { useLoadTheme } from "../hooks/theme" export const Route = createFileRoute("/_appRoot")({ component: RouteComponent, @@ -49,6 +50,9 @@ function RouteComponent() { const { online } = useNetworkState() const rootRef = React.useRef(null) + // Load custom theme if available + useLoadTheme() + // Sync when the app becomes visible again useEvent("visibilitychange", () => { if (document.visibilityState === "visible" && online) { @@ -174,6 +178,7 @@ function RouteComponent() { ] sendVoiceConversation({ type: "ADD_TOOLS", tools }) + return () => { sendVoiceConversation({ type: "REMOVE_TOOLS", toolNames: tools.map((tool) => tool.name) }) }