internal data sources (clock, static text), connection indicator, rendering fixes

DataSourceConfig refactored to enum: External/Clock/StaticText. Clock
generates formatted time via chrono, static text emits configured string.

ESP32: connection status indicator (green/red dot bottom-right), per-widget
clear before redraw, RenderEvent enum for local + server messages.

Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state.
Empty mappings passthrough raw source data for internal sources.
This commit is contained in:
2026-06-19 11:26:49 +02:00
parent b448fa15fe
commit a51d22649a
25 changed files with 630 additions and 214 deletions

View File

@@ -7,7 +7,7 @@ export interface DisplayHint {
h_align: HAlign
v_align: VAlign
}
export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook"
export type SourceType = "weather" | "media" | "rss" | "http_json" | "webhook" | "clock" | "static_text"
export type SizingType = "fixed" | "flex"
export type Direction = "row" | "column"
export type JustifyContent = "start" | "center" | "end" | "space_between" | "space_evenly"
@@ -29,14 +29,17 @@ export interface Widget {
export type CreateWidget = Widget
export type DataSourceConfig =
| { type: "external"; url: string | null; api_key: string | null; headers: [string, string][] }
| { type: "clock"; format: string; timezone: string }
| { type: "static_text"; text: string }
export interface DataSource {
id: number
name: string
source_type: SourceType
poll_interval_secs: number
url: string | null
api_key: string | null
headers: [string, string][]
config: DataSourceConfig
}
export interface Sizing {

View File

@@ -5,7 +5,7 @@ import {
useUpdateDataSource,
useDeleteDataSource,
} from "@/api/data-sources"
import type { DataSource, SourceType } from "@/api/types"
import type { DataSource, DataSourceConfig, SourceType } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
@@ -50,16 +50,24 @@ const SOURCE_TYPES: SourceType[] = [
"rss",
"http_json",
"webhook",
"clock",
"static_text",
]
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
function defaultConfigFor(sourceType: SourceType): DataSourceConfig {
if (sourceType === "clock") return { type: "clock", format: "%H:%M:%S", timezone: "UTC" }
if (sourceType === "static_text") return { type: "static_text", text: "" }
return { type: "external", url: null, api_key: null, headers: [] }
}
const EMPTY: DataSource = {
id: 0,
name: "",
source_type: "http_json",
poll_interval_secs: 300,
url: null,
api_key: null,
headers: [],
config: { type: "external", url: null, api_key: null, headers: [] },
}
export function DataSourcesPage() {
@@ -145,10 +153,10 @@ export function DataSourcesPage() {
<CardTitle className="text-base">{ds.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{ds.source_type}</Badge>
<span>every {ds.poll_interval_secs}s</span>
{ds.url && (
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">
{ds.url}
{ds.config.url}
</span>
)}
</CardDescription>
@@ -196,9 +204,13 @@ export function DataSourcesPage() {
onClick={save}
disabled={
!editing?.name ||
(editing.source_type !== "webhook" &&
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.poll_interval_secs <= 0) ||
(editing.source_type !== "webhook" && !editing.url)
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.config.type === "external" &&
!editing.config.url)
}
>
Save
@@ -302,6 +314,17 @@ function DataSourceForm({
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v })
const setConfig = (patch: Partial<DataSourceConfig>) =>
onChange({ ...value, config: { ...value.config, ...patch } as DataSourceConfig })
const onSourceTypeChange = (t: SourceType) => {
onChange({ ...value, source_type: t, config: defaultConfigFor(t) })
}
const isExternal = value.config.type === "external"
const isClock = value.config.type === "clock"
const isStaticText = value.config.type === "static_text"
return (
<div className="grid gap-4 py-2">
<div className="grid gap-2">
@@ -316,7 +339,7 @@ function DataSourceForm({
<Label>Source Type</Label>
<Select
value={value.source_type}
onValueChange={(v) => set("source_type", v as SourceType)}
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
>
<SelectTrigger>
<SelectValue />
@@ -330,23 +353,61 @@ function DataSourceForm({
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.url ?? ""}
onChange={(e) => set("url", e.target.value || null)}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.api_key ?? ""}
onChange={(e) => set("api_key", e.target.value || null)}
placeholder="Optional"
/>
</div>
{isExternal && (
<>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.config.url ?? ""}
onChange={(e) => setConfig({ url: e.target.value || null })}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.config.api_key ?? ""}
onChange={(e) => setConfig({ api_key: e.target.value || null })}
placeholder="Optional"
/>
</div>
</>
)}
{isClock && (
<>
<div className="grid gap-2">
<Label>Format</Label>
<Input
value={value.config.format}
onChange={(e) => setConfig({ format: e.target.value })}
placeholder="%H:%M:%S"
/>
</div>
<div className="grid gap-2">
<Label>Timezone</Label>
<Input
value={value.config.timezone}
onChange={(e) => setConfig({ timezone: e.target.value })}
placeholder="Europe/Warsaw"
/>
</div>
</>
)}
{isStaticText && (
<div className="grid gap-2">
<Label>Text</Label>
<Input
value={value.config.text}
onChange={(e) => setConfig({ text: e.target.value })}
placeholder="Hello world"
/>
</div>
)}
<div className="grid gap-2">
<Label>Poll Interval (seconds)</Label>
<Input
@@ -356,41 +417,44 @@ function DataSourceForm({
min={1}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
set("headers", [...value.headers, ["", ""]])
}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
{isExternal && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
setConfig({ headers: [...value.config.headers, ["", ""]] })
}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{value.config.headers.map(([k, v], i) => (
<HeaderRow
key={i}
headerKey={k}
headerValue={v}
onChangeKey={(newKey) => {
const next = [...value.config.headers] as [string, string][]
next[i] = [newKey, v]
setConfig({ headers: next })
}}
onChangeValue={(newVal) => {
const next = [...value.config.headers] as [string, string][]
next[i] = [k, newVal]
setConfig({ headers: next })
}}
onRemove={() =>
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
}
/>
))}
</div>
{value.headers.map(([k, v], i) => (
<HeaderRow
key={i}
headerKey={k}
headerValue={v}
onChangeKey={(newKey) => {
const next = [...value.headers] as [string, string][]
next[i] = [newKey, v]
set("headers", next)
}}
onChangeValue={(newVal) => {
const next = [...value.headers] as [string, string][]
next[i] = [k, newVal]
set("headers", next)
}}
onRemove={() =>
set("headers", value.headers.filter((_, idx) => idx !== i))
}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -199,8 +199,7 @@ export function WidgetsPage() {
onClick={save}
disabled={
!editing?.name ||
!editing.data_source_id ||
editing.mappings.length === 0
!editing.data_source_id
}
>
Save