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:
22
spa/src/api/theme.ts
Normal file
22
spa/src/api/theme.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api } from "./client"
|
||||
import type { ThemeConfig } from "./types"
|
||||
|
||||
const KEYS = {
|
||||
current: ["theme"] as const,
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useQuery({
|
||||
queryKey: KEYS.current,
|
||||
queryFn: () => api.get<ThemeConfig>("/theme"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTheme() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (theme: ThemeConfig) => api.put<ThemeConfig>("/theme", theme),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEYS.current }),
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,8 @@ export type DisplayHint = "icon_value" | "text_block" | "key_value"
|
||||
export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook"
|
||||
export type SizingType = "fixed" | "flex"
|
||||
export type Direction = "row" | "column"
|
||||
export type JustifyContent = "start" | "center" | "end" | "space_between" | "space_evenly"
|
||||
export type AlignItemsType = "start" | "center" | "end" | "stretch"
|
||||
|
||||
export interface KeyMapping {
|
||||
source_path: string
|
||||
@@ -40,6 +42,8 @@ export interface LayoutNode {
|
||||
direction?: Direction
|
||||
gap?: number
|
||||
padding?: number
|
||||
justify_content?: JustifyContent
|
||||
align_items?: AlignItemsType
|
||||
children?: LayoutChild[]
|
||||
}
|
||||
|
||||
@@ -62,3 +66,17 @@ export interface ClientInfo {
|
||||
addr: string
|
||||
connected_at: number
|
||||
}
|
||||
|
||||
export interface ThemeColor {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
primary: ThemeColor
|
||||
secondary: ThemeColor
|
||||
accent: ThemeColor
|
||||
text: ThemeColor
|
||||
background: ThemeColor
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Database,
|
||||
Box,
|
||||
Layers,
|
||||
Palette,
|
||||
Save,
|
||||
BookOpen,
|
||||
LogOut,
|
||||
@@ -30,6 +31,7 @@ const NAV = [
|
||||
{ to: "/data-sources", label: "Data Sources", icon: Database },
|
||||
{ to: "/widgets", label: "Widgets", icon: Box },
|
||||
{ to: "/layout", label: "Layout", icon: Layers },
|
||||
{ to: "/theme", label: "Theme", icon: Palette },
|
||||
{ to: "/presets", label: "Presets", icon: Save },
|
||||
{ to: "/guide", label: "Guide", icon: BookOpen },
|
||||
] as const
|
||||
|
||||
103
spa/src/components/layout-preview.tsx
Normal file
103
spa/src/components/layout-preview.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useMemo, useRef } from "react"
|
||||
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
|
||||
import { computeLayout } from "@/lib/layout-engine"
|
||||
|
||||
interface LayoutPreviewProps {
|
||||
layout: LayoutNode
|
||||
screenWidth: number
|
||||
screenHeight: number
|
||||
theme: ThemeConfig
|
||||
widgets: Widget[]
|
||||
}
|
||||
|
||||
function colorToCSS(c: { r: number; g: number; b: number }) {
|
||||
return `rgb(${c.r},${c.g},${c.b})`
|
||||
}
|
||||
|
||||
function collectWidgetIds(node: LayoutNode): number[] {
|
||||
if (node.type === "leaf") return node.widget_id !== undefined ? [node.widget_id] : []
|
||||
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
|
||||
}
|
||||
|
||||
export function LayoutPreview({
|
||||
layout,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
theme,
|
||||
widgets,
|
||||
}: LayoutPreviewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const bounds = useMemo(
|
||||
() => computeLayout(layout, { x: 0, y: 0, width: screenWidth, height: screenHeight }),
|
||||
[layout, screenWidth, screenHeight],
|
||||
)
|
||||
|
||||
const widgetIds = useMemo(() => collectWidgetIds(layout), [layout])
|
||||
const maxWidth = 600
|
||||
const scale = Math.min(1, maxWidth / screenWidth)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ maxWidth }}>
|
||||
<div
|
||||
style={{
|
||||
width: screenWidth * scale,
|
||||
height: screenHeight * scale,
|
||||
position: "relative",
|
||||
backgroundColor: colorToCSS(theme.background),
|
||||
overflow: "hidden",
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||
}}
|
||||
>
|
||||
{widgetIds.map((wid) => {
|
||||
const box = bounds.get(wid)
|
||||
if (!box) return null
|
||||
const w = widgets.find((w) => w.id === wid)
|
||||
return (
|
||||
<div
|
||||
key={wid}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: box.x * scale,
|
||||
top: box.y * scale,
|
||||
width: box.width * scale,
|
||||
height: box.height * scale,
|
||||
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||
boxSizing: "border-box",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
padding: 2 * scale,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10 * scale,
|
||||
color: colorToCSS(theme.text),
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{w?.name ?? `#${wid}`}
|
||||
</span>
|
||||
{w && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8 * scale,
|
||||
color: colorToCSS(theme.accent),
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{w.display_hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
spa/src/lib/layout-engine.ts
Normal file
93
spa/src/lib/layout-engine.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { LayoutNode, LayoutChild } from "@/api/types"
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function computeLayout(
|
||||
layout: LayoutNode,
|
||||
bounds: BoundingBox,
|
||||
): Map<number, BoundingBox> {
|
||||
const out = new Map<number, BoundingBox>()
|
||||
computeNode(layout, bounds, out)
|
||||
return out
|
||||
}
|
||||
|
||||
function computeNode(
|
||||
node: LayoutNode,
|
||||
bounds: BoundingBox,
|
||||
out: Map<number, BoundingBox>,
|
||||
) {
|
||||
if (node.type === "leaf") {
|
||||
if (node.widget_id !== undefined) {
|
||||
out.set(node.widget_id, bounds)
|
||||
}
|
||||
} else {
|
||||
computeContainer(node, bounds, out)
|
||||
}
|
||||
}
|
||||
|
||||
function computeContainer(
|
||||
container: LayoutNode,
|
||||
bounds: BoundingBox,
|
||||
out: Map<number, BoundingBox>,
|
||||
) {
|
||||
const pad = container.padding ?? 0
|
||||
const inner: BoundingBox = {
|
||||
x: bounds.x + pad,
|
||||
y: bounds.y + pad,
|
||||
width: Math.max(0, bounds.width - pad * 2),
|
||||
height: Math.max(0, bounds.height - pad * 2),
|
||||
}
|
||||
|
||||
const children = container.children ?? []
|
||||
if (children.length === 0) return
|
||||
|
||||
const isRow = container.direction === "row"
|
||||
const totalAxis = isRow ? inner.width : inner.height
|
||||
const gap = container.gap ?? 0
|
||||
const totalGap = gap * Math.max(0, children.length - 1)
|
||||
const available = Math.max(0, totalAxis - totalGap)
|
||||
|
||||
let fixedTotal = 0
|
||||
let flexTotal = 0
|
||||
for (const child of children) {
|
||||
if (child.sizing.type === "fixed") {
|
||||
fixedTotal += child.sizing.value
|
||||
} else {
|
||||
flexTotal += child.sizing.value
|
||||
}
|
||||
}
|
||||
|
||||
const flexSpace = Math.max(0, available - fixedTotal)
|
||||
|
||||
const childSizes: number[] = children.map((child: LayoutChild) => {
|
||||
if (child.sizing.type === "fixed") return child.sizing.value
|
||||
if (flexTotal > 0) {
|
||||
return Math.floor((flexSpace * child.sizing.value) / flexTotal)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const childrenTotal = childSizes.reduce((a, b) => a + b, 0)
|
||||
const remaining = Math.max(0, totalAxis - childrenTotal - totalGap)
|
||||
|
||||
// Default justify: Start
|
||||
let offset = 0
|
||||
let justifyGap = gap
|
||||
// SpaceBetween / SpaceEvenly could be added here if justify_content is on the DTO
|
||||
void remaining // unused for now, Start justification
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const childSize = childSizes[i]
|
||||
const childBounds: BoundingBox = isRow
|
||||
? { x: inner.x + offset, y: inner.y, width: childSize, height: inner.height }
|
||||
: { x: inner.x, y: inner.y + offset, width: inner.width, height: childSize }
|
||||
|
||||
computeNode(children[i].node, childBounds, out)
|
||||
offset += childSize + justifyGap
|
||||
}
|
||||
}
|
||||
@@ -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
276
spa/src/pages/theme.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { DashboardPage } from "@/pages/dashboard"
|
||||
import { DataSourcesPage } from "@/pages/data-sources"
|
||||
import { WidgetsPage } from "@/pages/widgets"
|
||||
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
||||
import { ThemePage } from "@/pages/theme"
|
||||
import { PresetsPage } from "@/pages/presets"
|
||||
import { GuidePage } from "@/pages/guide"
|
||||
import { LoginPage } from "@/pages/login"
|
||||
@@ -72,6 +73,12 @@ const layoutRoute = createRoute({
|
||||
component: LayoutBuilderPage,
|
||||
})
|
||||
|
||||
const themeRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/theme",
|
||||
component: ThemePage,
|
||||
})
|
||||
|
||||
const presetsRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/presets",
|
||||
@@ -91,6 +98,7 @@ const routeTree = rootRoute.addChildren([
|
||||
dataSourcesRoute,
|
||||
widgetsRoute,
|
||||
layoutRoute,
|
||||
themeRoute,
|
||||
presetsRoute,
|
||||
guideRoute,
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user