add theme

This commit is contained in:
2026-03-06 23:32:37 +01:00
parent 5feb2ea228
commit ee05c3f5d6
8 changed files with 126 additions and 18 deletions

View File

@@ -1,24 +1,23 @@
import React from 'react' import React from 'react'
import ReactFlow, { import {
ReactFlow,
Controls, Controls,
Background, Background,
MiniMap, MiniMap,
addEdge, addEdge,
applyEdgeChanges,
applyNodeChanges,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
Node, type Node,
Edge, type Edge,
Connection, type Connection,
NodeChange, type ColorMode,
EdgeChange, } from '@xyflow/react'
} from 'reactflow'
import ConfigNode from './components/graph/ConfigNode' import ConfigNode from './components/graph/ConfigNode'
import FunctionNode from './components/graph/FunctionNode' import FunctionNode from './components/graph/FunctionNode'
import RenderingNode from './components/graph/RenderingNode' import RenderingNode from './components/graph/RenderingNode'
import VariableNode from './components/graph/VariableNode' import VariableNode from './components/graph/VariableNode'
import FlowContext from './lib/flowContext' import FlowContext from './lib/flowContext'
import { useTheme } from './lib/themeContext'
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -27,6 +26,8 @@ import {
ContextMenuLabel, ContextMenuLabel,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { Button } from '@/components/ui/button'
import { Moon, Sun } from 'lucide-react'
// Generate short unique node ids when missing // Generate short unique node ids when missing
const genId = () => `node_${Math.random().toString(36).slice(2, 9)}` const genId = () => `node_${Math.random().toString(36).slice(2, 9)}`
@@ -48,6 +49,7 @@ const initialNodes: Node[] = [
const initialEdges: Edge[] = [{ id: 'e-config-render', source: 'config-1', target: 'render-1' }] const initialEdges: Edge[] = [{ id: 'e-config-render', source: 'config-1', target: 'render-1' }]
export default function App() { export default function App() {
const { theme, toggleTheme } = useTheme()
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges) const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const [rfInstance, setRfInstance] = React.useState<any | null>(null) const [rfInstance, setRfInstance] = React.useState<any | null>(null)
@@ -140,6 +142,16 @@ export default function App() {
return ( return (
<div className="reactflow-wrapper" ref={wrapperRef} onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }}> <div className="reactflow-wrapper" ref={wrapperRef} onContextMenu={onCanvasContextMenu} style={{ position: 'relative' }}>
<Button
variant="outline"
size="icon"
className="absolute top-3 right-3 z-10 nodrag nopan"
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<FlowContext.Provider value={{ nodes, setNodes, edges, setEdges }}> <FlowContext.Provider value={{ nodes, setNodes, edges, setEdges }}>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -151,6 +163,7 @@ export default function App() {
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
colorMode={theme as ColorMode}
fitView fitView
onInit={onInit} onInit={onInit}
> >

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useSt
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { yaml } from '@codemirror/lang-yaml' import { yaml } from '@codemirror/lang-yaml'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useTheme } from '../../lib/themeContext'
import { import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
@@ -29,7 +30,7 @@ type Props = {
export const ConfigNode = memo(function ConfigNode({ id, data }: Props) { export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
const [value, setValue] = useState<string>(data?.yaml ?? '# Enter YAML here\n') const [value, setValue] = useState<string>(data?.yaml ?? '# Enter YAML here\n')
const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? '' const storedYaml = ctx?.nodes?.find((n: any) => n.id === id)?.data?.yaml ?? ''
@@ -199,6 +200,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
ref={editorRef} ref={editorRef}
value={value} value={value}
height="180px" height="180px"
theme={theme}
extensions={extensions} extensions={extensions}
onChange={onChange} onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }} basicSetup={{ lineNumbers: true, foldGutter: false }}
@@ -208,7 +210,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data }: Props) {
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<div className="w-full text-xs text-gray-500">{storedYaml ? `${storedYaml.length} chars` : 'none'}</div> <div className="w-full text-xs text-muted-foreground">{storedYaml ? `${storedYaml.length} chars` : 'none'}</div>
</BaseNodeFooter> </BaseNodeFooter>
<InputHandle id="ain" /> <InputHandle id="ain" />

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useSt
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
import FlowContext from '../../lib/flowContext' import FlowContext from '../../lib/flowContext'
import { useTheme } from '../../lib/themeContext'
import { import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
@@ -23,6 +24,7 @@ return args[0];
export const FunctionNode = memo(function FunctionNode({ id, data }: Props) { export const FunctionNode = memo(function FunctionNode({ id, data }: Props) {
const [value, setValue] = useState<string>(data?.body ?? DEFAULT_BODY) const [value, setValue] = useState<string>(data?.body ?? DEFAULT_BODY)
const { theme } = useTheme()
const ctx = useContext(FlowContext) const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes const setNodes = ctx?.setNodes
@@ -66,6 +68,7 @@ export const FunctionNode = memo(function FunctionNode({ id, data }: Props) {
<CodeMirror <CodeMirror
value={value} value={value}
height="120px" height="120px"
theme={theme}
extensions={extensions} extensions={extensions}
onChange={onChange} onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }} basicSetup={{ lineNumbers: true, foldGutter: false }}

View File

@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { Handle, Position } from 'reactflow' import { Handle, Position } from '@xyflow/react'
import { Circle, CircleDashed } from 'lucide-react' import { Circle, CircleDashed } from 'lucide-react'
type NodeHandleProps = { type NodeHandleProps = {
@@ -26,7 +26,7 @@ export function InputHandle({ id }: NodeHandleProps) {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<CircleDashed className="w-4 h-4 text-gray-500 pointer-events-none" /> <CircleDashed className="w-4 h-4 text-muted-foreground pointer-events-none" />
</Handle> </Handle>
) )
} }
@@ -51,7 +51,7 @@ export function OutputHandle({ id }: NodeHandleProps) {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<Circle className="w-4 h-4 text-gray-800 pointer-events-none" /> <Circle className="w-4 h-4 text-foreground pointer-events-none" />
</Handle> </Handle>
) )
} }

View File

@@ -230,7 +230,7 @@ export const RenderingNode = memo(function RenderingNode({ id }: Props) {
<div className="p-3 text-xs text-red-700">{error.message}</div> <div className="p-3 text-xs text-red-700">{error.message}</div>
) )
) : ( ) : (
<pre className="text-xs text-gray-800 max-h-48 overflow-auto">{output}</pre> <pre className="text-xs text-foreground max-h-48 overflow-auto">{output}</pre>
)} )}
</BaseNodeContent> </BaseNodeContent>

83
src/lib/themeContext.tsx Normal file
View File

@@ -0,0 +1,83 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
const STORAGE_KEY = 'zui-theme'
export type Theme = 'light' | 'dark'
function readStored(): Theme | null {
try {
const s = localStorage.getItem(STORAGE_KEY)
if (s === 'light' || s === 'dark') return s
} catch (_) {}
return null
}
function systemPrefersDark(): boolean {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (_) {
return false
}
}
type ThemeContextValue = {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = readStored()
if (stored) return stored
return systemPrefersDark() ? 'dark' : 'light'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}, [theme])
useEffect(() => {
const m = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
if (readStored() != null) return
setThemeState(m.matches ? 'dark' : 'light')
}
m.addEventListener('change', handler)
return () => m.removeEventListener('change', handler)
}, [])
const setTheme = useCallback((next: Theme) => {
setThemeState(next)
try {
localStorage.setItem(STORAGE_KEY, next)
} catch (_) {}
}, [])
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === 'light' ? 'dark' : 'light'
try {
localStorage.setItem(STORAGE_KEY, next)
} catch (_) {}
return next
})
}, [])
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -1,11 +1,14 @@
import React from 'react' import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'
import { ThemeProvider } from './lib/themeContext'
import './styles.css' import './styles.css'
import 'reactflow/dist/style.css' import '@xyflow/react/dist/style.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode> </React.StrictMode>
) )

View File

@@ -11,7 +11,7 @@ body,
} }
body { body {
@apply bg-gray-50 text-gray-900; @apply bg-background text-foreground;
} }
.reactflow-wrapper { .reactflow-wrapper {
@@ -28,6 +28,10 @@ body {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06); box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
} }
.dark .react-flow__node .react-flow__handle {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.15);
}
/* Small helper for monospace pre output */ /* Small helper for monospace pre output */
pre { pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, 'Roboto Mono', 'Courier New', monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, 'Roboto Mono', 'Courier New', monospace;