theme config, layout preview, container alignment

Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence,
ThemeUpdate broadcast to ESP32 on save and initial connect.
Client: render engine uses theme colors, full-screen redraw on theme change.
SPA: theme page with color pickers + presets, layout preview with TS port
of layout engine, justify/align controls on containers.
DisplayHint refactored to struct (kind + h_align + v_align).
This commit is contained in:
2026-06-19 03:26:18 +02:00
parent 81a4167382
commit fe59b68c37
46 changed files with 1276 additions and 118 deletions

View File

@@ -1,7 +1,9 @@
import { useState, useCallback } from "react"
import { useLayout, useUpdateLayout } from "@/api/layout"
import { useWidgets } from "@/api/widgets"
import { useTheme } from "@/api/theme"
import type { LayoutNode, LayoutChild, Direction } from "@/api/types"
import { LayoutPreview } from "@/components/layout-preview"
import { Button } from "@/components/ui/button"
import {
Card,
@@ -37,6 +39,8 @@ import {
Trash2,
Save,
GripVertical,
Eye,
EyeOff,
} from "lucide-react"
import { toast } from "sonner"
@@ -115,12 +119,16 @@ function removeAtPath(root: LayoutNode, path: Path): LayoutNode {
export function LayoutBuilderPage() {
const { data: currentLayout, isLoading } = useLayout()
const { data: widgets = [] } = useWidgets()
const { data: theme } = useTheme()
const updateLayout = useUpdateLayout()
const [root, setRoot] = useState<LayoutNode | null>(null)
const [selected, setSelected] = useState<Path | null>(null)
const [initialized, setInitialized] = useState(false)
const [pendingDelete, setPendingDelete] = useState<Path | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [screenWidth, setScreenWidth] = useState(320)
const [screenHeight, setScreenHeight] = useState(240)
if (!initialized && currentLayout?.root) {
setRoot(structuredClone(currentLayout.root))
@@ -170,7 +178,7 @@ export function LayoutBuilderPage() {
function updateContainerProp(
path: Path,
prop: "gap" | "padding" | "direction",
prop: "gap" | "padding" | "direction" | "justify_content" | "align_items",
value: number | string,
) {
setRoot((r) =>
@@ -223,10 +231,23 @@ export function LayoutBuilderPage() {
Compose the display layout tree
</p>
</div>
<Button onClick={save}>
<Save className="mr-2 h-4 w-4" />
Save Layout
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowPreview((v) => !v)}
>
{showPreview ? (
<EyeOff className="mr-2 h-4 w-4" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
Preview
</Button>
<Button onClick={save}>
<Save className="mr-2 h-4 w-4" />
Save Layout
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
@@ -328,6 +349,65 @@ export function LayoutBuilderPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{showPreview && root && theme && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Layout Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label>W</Label>
<Input
type="number"
value={screenWidth}
onChange={(e) => setScreenWidth(Number(e.target.value))}
className="w-20"
min={1}
/>
</div>
<span className="text-muted-foreground">x</span>
<div className="flex items-center gap-2">
<Label>H</Label>
<Input
type="number"
value={screenHeight}
onChange={(e) => setScreenHeight(Number(e.target.value))}
className="w-20"
min={1}
/>
</div>
<div className="flex gap-1">
{([
[320, 240],
[240, 135],
[128, 64],
] as const).map(([w, h]) => (
<Button
key={`${w}x${h}`}
variant="outline"
size="sm"
onClick={() => {
setScreenWidth(w)
setScreenHeight(h)
}}
>
{w}x{h}
</Button>
))}
</div>
</div>
<LayoutPreview
layout={root}
screenWidth={screenWidth}
screenHeight={screenHeight}
theme={theme}
widgets={widgets}
/>
</CardContent>
</Card>
)}
</div>
)
}
@@ -420,7 +500,7 @@ function ContainerProps({
}: {
node: LayoutNode
path: Path
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction", value: number | string) => void
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void
@@ -469,6 +549,43 @@ function ContainerProps({
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-2">
<Label>Justify</Label>
<Select
value={node.justify_content ?? "start"}
onValueChange={(v) => onUpdateProp(path, "justify_content", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start">Start</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="end">End</SelectItem>
<SelectItem value="space_between">Space Between</SelectItem>
<SelectItem value="space_evenly">Space Evenly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Align</Label>
<Select
value={node.align_items ?? "stretch"}
onValueChange={(v) => onUpdateProp(path, "align_items", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start">Start</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="end">End</SelectItem>
<SelectItem value="stretch">Stretch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{!isRoot && <SizingEditor path={path} onUpdate={onUpdateSizing} />}
<div className="grid gap-2">
<Label>Add Child</Label>

276
spa/src/pages/theme.tsx Normal file
View File

@@ -0,0 +1,276 @@
import { useState, useEffect } from "react"
import { useTheme, useUpdateTheme } from "@/api/theme"
import type { ThemeColor, ThemeConfig } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Save } from "lucide-react"
import { toast } from "sonner"
function colorToHex(c: ThemeColor): string {
const r = Math.min(255, Math.max(0, c.r)).toString(16).padStart(2, "0")
const g = Math.min(255, Math.max(0, c.g)).toString(16).padStart(2, "0")
const b = Math.min(255, Math.max(0, c.b)).toString(16).padStart(2, "0")
return `#${r}${g}${b}`
}
function hexToColor(hex: string): ThemeColor | null {
const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
if (!m) return null
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }
}
const PRESETS: Record<string, ThemeConfig> = {
"Default Dark": {
primary: { r: 0, g: 122, b: 204 },
secondary: { r: 136, g: 136, b: 136 },
accent: { r: 233, g: 69, b: 96 },
text: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
},
Light: {
primary: { r: 0, g: 102, b: 170 },
secondary: { r: 102, g: 102, b: 102 },
accent: { r: 214, g: 59, b: 80 },
text: { r: 26, g: 26, b: 26 },
background: { r: 245, g: 245, b: 245 },
},
"Solarized Dark": {
primary: { r: 38, g: 139, b: 210 },
secondary: { r: 131, g: 148, b: 150 },
accent: { r: 203, g: 75, b: 22 },
text: { r: 147, g: 161, b: 161 },
background: { r: 0, g: 43, b: 54 },
},
Nord: {
primary: { r: 136, g: 192, b: 208 },
secondary: { r: 129, g: 161, b: 193 },
accent: { r: 191, g: 97, b: 106 },
text: { r: 236, g: 239, b: 244 },
background: { r: 46, g: 52, b: 64 },
},
}
const COLOR_FIELDS: (keyof ThemeConfig)[] = [
"primary",
"secondary",
"accent",
"text",
"background",
]
function ColorPicker({
label,
color,
onChange,
}: {
label: string
color: ThemeColor
onChange: (c: ThemeColor) => void
}) {
const hex = colorToHex(color)
return (
<div className="grid gap-2">
<Label className="capitalize">{label}</Label>
<div className="flex gap-2">
<input
type="color"
value={hex}
onChange={(e) => {
const c = hexToColor(e.target.value)
if (c) onChange(c)
}}
className="h-9 w-12 cursor-pointer rounded border p-0.5"
/>
<Input
value={hex.toUpperCase()}
onChange={(e) => {
const c = hexToColor(e.target.value)
if (c) onChange(c)
}}
className="font-mono"
maxLength={7}
/>
</div>
</div>
)
}
export function ThemePage() {
const { data: serverTheme, isLoading } = useTheme()
const updateTheme = useUpdateTheme()
const [theme, setTheme] = useState<ThemeConfig | null>(null)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
if (!initialized && serverTheme) {
setTheme(structuredClone(serverTheme))
setInitialized(true)
}
}, [serverTheme, initialized])
function setColor(field: keyof ThemeConfig, c: ThemeColor) {
setTheme((t) => (t ? { ...t, [field]: c } : t))
}
function applyPreset(name: string) {
setTheme(structuredClone(PRESETS[name]))
}
async function save() {
if (!theme) return
try {
await updateTheme.mutateAsync(theme)
toast.success("Theme saved & pushed to clients")
} catch (e) {
toast.error(String(e))
}
}
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
if (!theme) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Theme</h1>
<p className="text-muted-foreground text-sm">Configure display colors</p>
</div>
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<p className="text-muted-foreground">No theme configured. Pick a preset to start:</p>
<div className="flex flex-wrap gap-2">
{Object.keys(PRESETS).map((name) => (
<Button key={name} variant="outline" onClick={() => applyPreset(name)}>
{name}
</Button>
))}
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Theme</h1>
<p className="text-muted-foreground text-sm">Configure display colors</p>
</div>
<Button onClick={save} disabled={updateTheme.isPending}>
<Save className="mr-2 h-4 w-4" />
Save Theme
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
{/* Color pickers */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Colors</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
{COLOR_FIELDS.map((field) => (
<ColorPicker
key={field}
label={field}
color={theme[field]}
onChange={(c) => setColor(field, c)}
/>
))}
</CardContent>
</Card>
{/* Right column: presets + preview */}
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Presets</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2">
{Object.keys(PRESETS).map((name) => (
<Button
key={name}
variant="outline"
size="sm"
onClick={() => applyPreset(name)}
>
{name}
</Button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Preview</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Color swatches */}
<div className="flex gap-2">
{COLOR_FIELDS.map((field) => (
<div key={field} className="flex flex-col items-center gap-1">
<div
className="h-8 w-8 rounded border"
style={{ backgroundColor: colorToHex(theme[field]) }}
/>
<span className="text-muted-foreground text-[10px] capitalize">
{field}
</span>
</div>
))}
</div>
{/* Sample display */}
<div
className="rounded-md p-4"
style={{ backgroundColor: colorToHex(theme.background) }}
>
<p
className="text-sm font-medium"
style={{ color: colorToHex(theme.primary) }}
>
Primary Heading
</p>
<p
className="text-xs"
style={{ color: colorToHex(theme.text) }}
>
Sample text content displayed on the device.
</p>
<div className="mt-2 flex gap-2">
<Badge
style={{
backgroundColor: colorToHex(theme.accent),
color: colorToHex(theme.text),
}}
>
Accent
</Badge>
<Badge
variant="outline"
style={{
borderColor: colorToHex(theme.secondary),
color: colorToHex(theme.secondary),
}}
>
Secondary
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}