remove all modals, inline editing, live layout preview, clock preview
all Dialog/AlertDialog removed from widgets, data-sources, presets, layout-builder pages. replaced with inline card expansion for edit/create and inline confirm bars for delete. data source form: live clock format preview with 1s tick, timezone validation against Intl.supportedValuesOf. layout preview: fetches live widget data via useWidgetPreview, renders formatted content based on display_hint kind instead of widget names.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef } from "react"
|
||||
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
|
||||
import { computeLayout } from "@/lib/layout-engine"
|
||||
import { useWidgetPreview } from "@/api/widgets"
|
||||
|
||||
interface LayoutPreviewProps {
|
||||
layout: LayoutNode
|
||||
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
|
||||
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
|
||||
}
|
||||
|
||||
function formatPreviewData(
|
||||
kind: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): string {
|
||||
if (!data) return ""
|
||||
const entries = Object.entries(data)
|
||||
if (entries.length === 0) return ""
|
||||
|
||||
switch (kind) {
|
||||
case "key_value":
|
||||
return entries.map(([k, v]) => `${k}: ${v}`).join("\n")
|
||||
case "text_block":
|
||||
return entries.map(([, v]) => String(v ?? "")).join("\n")
|
||||
case "icon_value":
|
||||
return entries.map(([, v]) => String(v ?? "")).join(" ")
|
||||
default:
|
||||
return entries.map(([, v]) => String(v ?? "")).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetCell({ wid, widget, scale, theme }: {
|
||||
wid: number
|
||||
widget: Widget | undefined
|
||||
scale: number
|
||||
theme: ThemeConfig
|
||||
}) {
|
||||
const { data } = useWidgetPreview(wid, true)
|
||||
const hAlign = widget?.display_hint?.h_align ?? "left"
|
||||
const vAlign = widget?.display_hint?.v_align ?? "top"
|
||||
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
|
||||
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
|
||||
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
|
||||
|
||||
const kind = widget?.display_hint?.kind ?? "text_block"
|
||||
const previewText = formatPreviewData(kind, data as Record<string, unknown> | undefined)
|
||||
const hasData = previewText.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: flexAlign,
|
||||
justifyContent: flexJustify,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{hasData ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8 * scale,
|
||||
color: colorToCSS(theme.text),
|
||||
textAlign,
|
||||
lineHeight: 1.3,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{previewText}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10 * scale,
|
||||
color: colorToCSS(theme.text),
|
||||
textAlign,
|
||||
lineHeight: 1.2,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
{widget?.name ?? `#${wid}`}
|
||||
</span>
|
||||
{widget && (
|
||||
<span style={{ fontSize: 7 * scale, color: colorToCSS(theme.accent), textAlign, opacity: 0.5 }}>
|
||||
{kind}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutPreview({
|
||||
layout,
|
||||
screenWidth,
|
||||
@@ -54,11 +140,6 @@ export function LayoutPreview({
|
||||
const box = bounds.get(wid)
|
||||
if (!box) return null
|
||||
const w = widgets.find((w) => w.id === wid)
|
||||
const hAlign = w?.display_hint?.h_align ?? "left"
|
||||
const vAlign = w?.display_hint?.v_align ?? "top"
|
||||
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
|
||||
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
|
||||
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
|
||||
return (
|
||||
<div
|
||||
key={wid}
|
||||
@@ -70,35 +151,11 @@ export function LayoutPreview({
|
||||
height: box.height * scale,
|
||||
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||
boxSizing: "border-box",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: flexAlign,
|
||||
justifyContent: flexJustify,
|
||||
overflow: "hidden",
|
||||
padding: 2 * scale,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10 * scale,
|
||||
color: colorToCSS(theme.text),
|
||||
textAlign,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{w?.name ?? `#${wid}`}
|
||||
</span>
|
||||
{w && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8 * scale,
|
||||
color: colorToCSS(theme.accent),
|
||||
textAlign,
|
||||
}}
|
||||
>
|
||||
{w.display_hint.kind}
|
||||
</span>
|
||||
)}
|
||||
<WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
useDataSources,
|
||||
useCreateDataSource,
|
||||
@@ -14,13 +14,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
@@ -30,28 +23,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Plus, Pencil, Trash2, X, Eye, EyeOff } from "lucide-react"
|
||||
import { Plus, Pencil, Trash2, X, Eye, EyeOff, ChevronUp } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const SOURCE_TYPES: SourceType[] = [
|
||||
"weather",
|
||||
"media",
|
||||
"rss",
|
||||
"http_json",
|
||||
"webhook",
|
||||
"clock",
|
||||
"static_text",
|
||||
"weather", "media", "rss", "http_json", "webhook", "clock", "static_text",
|
||||
]
|
||||
|
||||
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
|
||||
@@ -70,83 +47,158 @@ const EMPTY: DataSource = {
|
||||
config: { type: "external", url: null, api_key: null, headers: [] },
|
||||
}
|
||||
|
||||
const VALID_TIMEZONES = new Set(Intl.supportedValuesOf("timeZone"))
|
||||
|
||||
const STRFTIME_MAP: Record<string, (d: Date) => string> = {
|
||||
"%H": (d) => String(d.getHours()).padStart(2, "0"),
|
||||
"%M": (d) => String(d.getMinutes()).padStart(2, "0"),
|
||||
"%S": (d) => String(d.getSeconds()).padStart(2, "0"),
|
||||
"%I": (d) => String(d.getHours() % 12 || 12).padStart(2, "0"),
|
||||
"%p": (d) => (d.getHours() >= 12 ? "PM" : "AM"),
|
||||
"%Y": (d) => String(d.getFullYear()),
|
||||
"%m": (d) => String(d.getMonth() + 1).padStart(2, "0"),
|
||||
"%d": (d) => String(d.getDate()).padStart(2, "0"),
|
||||
}
|
||||
|
||||
function formatClockPreview(fmt: string, tz: string): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(new Date())
|
||||
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
|
||||
const h24 = Number(get("hour"))
|
||||
const fakeDate = new Date(2000, 0, 1, h24, Number(get("minute")), Number(get("second")))
|
||||
|
||||
let result = fmt
|
||||
for (const [token, fn] of Object.entries(STRFTIME_MAP)) {
|
||||
if (token === "%Y") result = result.replaceAll(token, get("year"))
|
||||
else if (token === "%m") result = result.replaceAll(token, get("month"))
|
||||
else if (token === "%d") result = result.replaceAll(token, get("day"))
|
||||
else result = result.replaceAll(token, fn(fakeDate))
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return "invalid timezone"
|
||||
}
|
||||
}
|
||||
|
||||
function isValidSave(ds: DataSource): boolean {
|
||||
if (!ds.name) return false
|
||||
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.poll_interval_secs <= 0) return false
|
||||
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.config.type === "external" && !ds.config.url) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function DataSourcesPage() {
|
||||
const { data: sources = [], isLoading } = useDataSources()
|
||||
const create = useCreateDataSource()
|
||||
const update = useUpdateDataSource()
|
||||
const del = useDeleteDataSource()
|
||||
|
||||
const [editing, setEditing] = useState<DataSource | null>(null)
|
||||
const [deleting, setDeleting] = useState<number | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editingData, setEditingData] = useState<DataSource | null>(null)
|
||||
const [newSource, setNewSource] = useState<DataSource | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||
|
||||
function openNew() {
|
||||
const nextId =
|
||||
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
|
||||
setEditing({ ...EMPTY, id: nextId })
|
||||
const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
|
||||
setNewSource({ ...EMPTY, id: nextId })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
function openEdit(ds: DataSource) {
|
||||
setEditing({ ...ds })
|
||||
function startEdit(ds: DataSource) {
|
||||
setEditingId(ds.id)
|
||||
setEditingData({ ...ds })
|
||||
setNewSource(null)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
const isNew = !sources.some((s) => s.id === editing.id)
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setEditingData(null)
|
||||
setNewSource(null)
|
||||
}
|
||||
|
||||
async function saveExisting() {
|
||||
if (!editingData) return
|
||||
try {
|
||||
if (isNew) {
|
||||
await create.mutateAsync(editing)
|
||||
toast.success("Data source created")
|
||||
} else {
|
||||
await update.mutateAsync(editing)
|
||||
toast.success("Data source updated")
|
||||
}
|
||||
setEditing(null)
|
||||
await update.mutateAsync(editingData)
|
||||
toast.success("Data source updated")
|
||||
setEditingId(null)
|
||||
setEditingData(null)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (deleting == null) return
|
||||
async function saveNew() {
|
||||
if (!newSource) return
|
||||
try {
|
||||
await del.mutateAsync(deleting)
|
||||
await create.mutateAsync(newSource)
|
||||
toast.success("Data source created")
|
||||
setNewSource(null)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(id: number) {
|
||||
try {
|
||||
await del.mutateAsync(id)
|
||||
toast.success("Data source deleted")
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
setDeleting(null)
|
||||
setConfirmingDelete(null)
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-muted-foreground p-4">Loading…</div>
|
||||
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Data Sources
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure external data feeds
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
|
||||
<p className="text-muted-foreground text-sm">Configure data feeds</p>
|
||||
</div>
|
||||
<Button onClick={openNew}>
|
||||
<Button onClick={openNew} disabled={!!newSource}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Source
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No data sources configured yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{sources.map((ds) => (
|
||||
<div className="grid gap-3">
|
||||
{newSource && (
|
||||
<Card className="border-primary">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base">New Data Source</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<DataSourceForm value={newSource} onChange={setNewSource} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||
<Button size="sm" onClick={saveNew} disabled={!isValidSave(newSource)}>Save</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{sources.length === 0 && !newSource && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No data sources configured yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{sources.map((ds) => {
|
||||
const isEditing = editingId === ds.id
|
||||
const isDeleting = confirmingDelete === ds.id
|
||||
|
||||
return (
|
||||
<Card key={ds.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div className="space-y-1">
|
||||
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
|
||||
<Badge variant="secondary">{ds.source_type}</Badge>
|
||||
{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.config.url}
|
||||
</span>
|
||||
<span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(ds)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
|
||||
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleting(ds.id)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isDeleting && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
|
||||
<span className="text-sm flex-1">Delete? Widgets referencing it will lose their feed.</span>
|
||||
<Button size="sm" variant="destructive" onClick={() => confirmDelete(ds.id)}>Delete</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{isEditing && editingData && (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<DataSourceForm value={editingData} onChange={setEditingData} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||
<Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Create Dialog */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing && sources.some((s) => s.id === editing.id)
|
||||
? "Edit Data Source"
|
||||
: "New Data Source"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editing && (
|
||||
<DataSourceForm value={editing} onChange={setEditing} />
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={
|
||||
!editing?.name ||
|
||||
(EXTERNAL_TYPES.includes(editing.source_type) &&
|
||||
editing.source_type !== "webhook" &&
|
||||
editing.poll_interval_secs <= 0) ||
|
||||
(EXTERNAL_TYPES.includes(editing.source_type) &&
|
||||
editing.source_type !== "webhook" &&
|
||||
editing.config.type === "external" &&
|
||||
!editing.config.url)
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog
|
||||
open={deleting != null}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete data source?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove this data source. Widgets referencing
|
||||
it will lose their feed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -251,11 +255,7 @@ function isSensitiveKey(key: string) {
|
||||
}
|
||||
|
||||
function HeaderRow({
|
||||
headerKey,
|
||||
headerValue,
|
||||
onChangeKey,
|
||||
onChangeValue,
|
||||
onRemove,
|
||||
headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
|
||||
}: {
|
||||
headerKey: string
|
||||
headerValue: string
|
||||
@@ -268,12 +268,7 @@ function HeaderRow({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={headerKey}
|
||||
onChange={(e) => onChangeKey(e.target.value)}
|
||||
placeholder="key"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={sensitive && !visible ? "password" : "text"}
|
||||
@@ -283,17 +278,8 @@ function HeaderRow({
|
||||
className={sensitive ? "pr-9" : ""}
|
||||
/>
|
||||
{sensitive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-0 right-0 h-full w-9"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
|
||||
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -304,13 +290,28 @@ function HeaderRow({
|
||||
)
|
||||
}
|
||||
|
||||
function DataSourceForm({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DataSource
|
||||
onChange: (ds: DataSource) => void
|
||||
}) {
|
||||
function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
|
||||
const [preview, setPreview] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setPreview(formatClockPreview(format, timezone))
|
||||
const id = setInterval(() => setPreview(formatClockPreview(format, timezone)), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [format, timezone])
|
||||
|
||||
const validTz = VALID_TIMEZONES.has(timezone)
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-mono">{preview}</p>
|
||||
{timezone && !validTz && (
|
||||
<p className="text-destructive text-xs">Unknown timezone</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataSourceForm({ value, onChange }: { value: DataSource; onChange: (ds: DataSource) => void }) {
|
||||
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
|
||||
onChange({ ...value, [k]: v })
|
||||
|
||||
@@ -326,29 +327,18 @@ function DataSourceForm({
|
||||
const isStaticText = value.config.type === "static_text"
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={value.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="e.g. weather"
|
||||
/>
|
||||
<Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Source Type</Label>
|
||||
<Select
|
||||
value={value.source_type}
|
||||
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -358,20 +348,11 @@ function DataSourceForm({
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
value={value.config.url ?? ""}
|
||||
onChange={(e) => setConfig({ url: e.target.value || null })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -380,55 +361,33 @@ function DataSourceForm({
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>Format</Label>
|
||||
<Input
|
||||
value={value.config.format}
|
||||
onChange={(e) => setConfig({ format: e.target.value })}
|
||||
placeholder="%H:%M:%S"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
|
||||
</div>
|
||||
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStaticText && (
|
||||
<div className="grid gap-2">
|
||||
<Label>Text</Label>
|
||||
<Input
|
||||
value={value.config.text}
|
||||
onChange={(e) => setConfig({ text: e.target.value })}
|
||||
placeholder="Hello world"
|
||||
/>
|
||||
<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
|
||||
type="number"
|
||||
value={value.poll_interval_secs}
|
||||
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
|
||||
min={1}
|
||||
/>
|
||||
<Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
|
||||
</div>
|
||||
|
||||
{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, ["", ""]] })
|
||||
}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
@@ -448,9 +407,7 @@ function DataSourceForm({
|
||||
next[i] = [k, newVal]
|
||||
setConfig({ headers: next })
|
||||
}}
|
||||
onRemove={() =>
|
||||
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
|
||||
}
|
||||
onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,16 +18,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
|
||||
onAddContainer={(path, dir) =>
|
||||
addChild(path, makeContainerChild(dir))
|
||||
}
|
||||
onRemove={() => setPendingDelete(selected)}
|
||||
pendingDelete={pendingDelete}
|
||||
onRequestDelete={() => setPendingDelete(selected)}
|
||||
onConfirmDelete={() => {
|
||||
if (pendingDelete !== null) {
|
||||
removeChild(pendingDelete)
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
onCancelDelete={() => setPendingDelete(null)}
|
||||
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||
isRoot={selected.length === 0}
|
||||
widgets={widgets}
|
||||
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
|
||||
<LeafProps
|
||||
path={selected}
|
||||
widgetId={selectedNode?.widget_id ?? 0}
|
||||
onRemove={() => setPendingDelete(selected)}
|
||||
pendingDelete={pendingDelete}
|
||||
onRequestDelete={() => setPendingDelete(selected)}
|
||||
onConfirmDelete={() => {
|
||||
if (pendingDelete !== null) {
|
||||
removeChild(pendingDelete)
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
onCancelDelete={() => setPendingDelete(null)}
|
||||
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||
widgets={widgets}
|
||||
sizing={
|
||||
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(o) => !o && setPendingDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingDelete?.length === 0
|
||||
? "Clear entire layout?"
|
||||
: "Remove this node?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.length === 0
|
||||
? "This will remove the entire layout tree. You can rebuild it afterward."
|
||||
: "This will remove the selected node and all its children."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (pendingDelete !== null) {
|
||||
removeChild(pendingDelete)
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{showPreview && root && theme && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -493,7 +466,10 @@ function ContainerProps({
|
||||
onUpdateProp,
|
||||
onAddWidget,
|
||||
onAddContainer,
|
||||
onRemove,
|
||||
pendingDelete,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onUpdateSizing,
|
||||
isRoot,
|
||||
widgets,
|
||||
@@ -503,7 +479,10 @@ function ContainerProps({
|
||||
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
|
||||
pendingDelete: Path | null
|
||||
onRequestDelete: () => void
|
||||
onConfirmDelete: () => void
|
||||
onCancelDelete: () => void
|
||||
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
||||
isRoot: boolean
|
||||
widgets: { id: number; name: string }[]
|
||||
@@ -622,14 +601,37 @@ function ContainerProps({
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
{isRoot ? "Clear Layout" : "Remove"}
|
||||
</Button>
|
||||
{pendingDelete !== null &&
|
||||
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-destructive">
|
||||
{isRoot
|
||||
? "Clear entire layout? You can rebuild afterward."
|
||||
: "Remove this node and all its children?"}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onConfirmDelete}
|
||||
>
|
||||
{isRoot ? "Clear" : "Remove"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCancelDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
{isRoot ? "Clear Layout" : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -637,14 +639,20 @@ function ContainerProps({
|
||||
function LeafProps({
|
||||
path,
|
||||
widgetId,
|
||||
onRemove,
|
||||
pendingDelete,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onUpdateSizing,
|
||||
widgets,
|
||||
sizing,
|
||||
}: {
|
||||
path: Path
|
||||
widgetId: number
|
||||
onRemove: () => void
|
||||
pendingDelete: Path | null
|
||||
onRequestDelete: () => void
|
||||
onConfirmDelete: () => void
|
||||
onCancelDelete: () => void
|
||||
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
||||
widgets: { id: number; name: string }[]
|
||||
sizing?: LayoutChild["sizing"]
|
||||
@@ -657,10 +665,31 @@ function LeafProps({
|
||||
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
|
||||
</div>
|
||||
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
|
||||
<Button variant="destructive" size="sm" onClick={onRemove}>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Remove
|
||||
</Button>
|
||||
{pendingDelete !== null &&
|
||||
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-destructive">
|
||||
Remove this widget?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onConfirmDelete}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCancelDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" onClick={onRequestDelete}>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,26 +15,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Save, Upload, Trash2 } from "lucide-react"
|
||||
import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function PresetsPage() {
|
||||
@@ -45,22 +27,22 @@ export function PresetsPage() {
|
||||
const loadPreset = useLoadPreset()
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveName, setSaveName] = useState("")
|
||||
const [deleting, setDeleting] = useState<number | null>(null)
|
||||
const [presetName, setPresetName] = useState("")
|
||||
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||
|
||||
async function saveAsPreset() {
|
||||
if (!layout || !saveName) return
|
||||
if (!layout || !presetName) return
|
||||
const nextId =
|
||||
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
|
||||
try {
|
||||
await createPreset.mutateAsync({
|
||||
id: nextId,
|
||||
name: saveName,
|
||||
name: presetName,
|
||||
layout,
|
||||
})
|
||||
toast.success("Preset saved")
|
||||
setSaving(false)
|
||||
setSaveName("")
|
||||
setPresetName("")
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
@@ -75,15 +57,14 @@ export function PresetsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (deleting == null) return
|
||||
async function confirmDelete(id: number) {
|
||||
try {
|
||||
await deletePreset.mutateAsync(deleting)
|
||||
await deletePreset.mutateAsync(id)
|
||||
toast.success("Preset deleted")
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
setDeleting(null)
|
||||
setConfirmingDelete(null)
|
||||
}
|
||||
|
||||
function nodeCount(node: Preset["layout"]["root"]): number {
|
||||
@@ -104,12 +85,50 @@ export function PresetsPage() {
|
||||
Save and restore layout configurations
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSaving(true)} disabled={!layout}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Current Layout
|
||||
<Button
|
||||
onClick={() => setSaving((v) => !v)}
|
||||
disabled={!layout}
|
||||
variant={saving ? "secondary" : "default"}
|
||||
>
|
||||
{saving ? (
|
||||
<ChevronUp className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Close" : "Save Current Layout"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saving && (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base">Save Current Layout as Preset</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2 pb-3">
|
||||
<Input
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="e.g. dashboard"
|
||||
onKeyDown={(e) => e.key === "Enter" && saveAsPreset()}
|
||||
autoFocus
|
||||
/>
|
||||
<Button onClick={saveAsPreset} disabled={!presetName} size="sm">
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSaving(false)
|
||||
setPresetName("")
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{presets.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
@@ -128,69 +147,47 @@ export function PresetsPage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
Load
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleting(p.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{confirmingDelete === p.id ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground self-center mr-1">
|
||||
Delete?
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(p.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingDelete(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
Load
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfirmingDelete(p.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={saving} onOpenChange={(o) => !o && setSaving(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Current Layout as Preset</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Preset Name</Label>
|
||||
<Input
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="e.g. dashboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaving(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveAsPreset} disabled={!saveName}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting != null}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete preset?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove this preset.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
@@ -32,18 +25,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Plus, Pencil, Trash2, X, Eye } from "lucide-react"
|
||||
import { Plus, Pencil, Trash2, X, Eye, ChevronUp } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
|
||||
@@ -64,52 +47,72 @@ export function WidgetsPage() {
|
||||
const update = useUpdateWidget()
|
||||
const del = useDeleteWidget()
|
||||
|
||||
const [editing, setEditing] = useState<Widget | null>(null)
|
||||
const [deleting, setDeleting] = useState<number | null>(null)
|
||||
const [previewing, setPreviewing] = useState<number | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editingData, setEditingData] = useState<Widget | null>(null)
|
||||
const [newWidget, setNewWidget] = useState<Widget | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<number | null>(null)
|
||||
|
||||
function openNew() {
|
||||
const nextId =
|
||||
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
|
||||
setEditing({
|
||||
...EMPTY,
|
||||
id: nextId,
|
||||
data_source_id: sources[0]?.id ?? 0,
|
||||
})
|
||||
setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 })
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing) return
|
||||
const isNew = !widgets.some((w) => w.id === editing.id)
|
||||
function startEdit(w: Widget) {
|
||||
setEditingId(w.id)
|
||||
setEditingData({ ...w })
|
||||
setNewWidget(null)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setEditingData(null)
|
||||
setNewWidget(null)
|
||||
}
|
||||
|
||||
async function saveExisting() {
|
||||
if (!editingData) return
|
||||
try {
|
||||
if (isNew) {
|
||||
await create.mutateAsync(editing)
|
||||
toast.success("Widget created")
|
||||
} else {
|
||||
await update.mutateAsync(editing)
|
||||
toast.success("Widget updated")
|
||||
}
|
||||
setEditing(null)
|
||||
await update.mutateAsync(editingData)
|
||||
toast.success("Widget updated")
|
||||
setEditingId(null)
|
||||
setEditingData(null)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (deleting == null) return
|
||||
async function saveNew() {
|
||||
if (!newWidget) return
|
||||
try {
|
||||
await del.mutateAsync(deleting)
|
||||
await create.mutateAsync(newWidget)
|
||||
toast.success("Widget created")
|
||||
setNewWidget(null)
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(id: number) {
|
||||
try {
|
||||
await del.mutateAsync(id)
|
||||
toast.success("Widget deleted")
|
||||
} catch (e) {
|
||||
toast.error(String(e))
|
||||
}
|
||||
setDeleting(null)
|
||||
setConfirmingDelete(null)
|
||||
}
|
||||
|
||||
function togglePreview(id: number) {
|
||||
setPreviewingId(previewingId === id ? null : id)
|
||||
}
|
||||
|
||||
const sourceName = (id: number) =>
|
||||
sources.find((s) => s.id === id)?.name ?? `#${id}`
|
||||
|
||||
if (isLoading) return <div className="text-muted-foreground p-4">Loading…</div>
|
||||
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -120,21 +123,42 @@ export function WidgetsPage() {
|
||||
Display primitives bound to data sources
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openNew}>
|
||||
<Button onClick={openNew} disabled={!!newWidget}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Widget
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{widgets.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No widgets configured yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{widgets.map((w) => (
|
||||
<div className="grid gap-3">
|
||||
{newWidget && (
|
||||
<Card className="border-primary">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base">New Widget</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<WidgetForm value={newWidget} onChange={setNewWidget} sources={sources} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||
<Button size="sm" onClick={saveNew} disabled={!newWidget.name || !newWidget.data_source_id}>Save</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{widgets.length === 0 && !newWidget && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No widgets configured yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{widgets.map((w) => {
|
||||
const isEditing = editingId === w.id
|
||||
const isDeleting = confirmingDelete === w.id
|
||||
const isPreviewing = previewingId === w.id
|
||||
|
||||
return (
|
||||
<Card key={w.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<div className="space-y-1">
|
||||
@@ -142,143 +166,65 @@ export function WidgetsPage() {
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{w.display_hint.kind}</Badge>
|
||||
<span>source: {sourceName(w.data_source_id)}</span>
|
||||
<span>{w.mappings.length} mapping(s)</span>
|
||||
{w.mappings.length > 0 && <span>{w.mappings.length} mapping(s)</span>}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setPreviewing(w.id)}
|
||||
title="Preview current data"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
|
||||
{isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditing({ ...w })}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
|
||||
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleting(w.id)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isDeleting && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
|
||||
<span className="text-sm flex-1">Delete this widget? Layout references will become dangling.</span>
|
||||
<Button size="sm" variant="destructive" onClick={() => confirmDelete(w.id)}>Delete</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{isPreviewing && !isEditing && (
|
||||
<CardContent className="pt-0">
|
||||
<WidgetPreviewInline widgetId={w.id} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{isEditing && editingData && (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<WidgetForm value={editingData} onChange={setEditingData} sources={sources} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||
<Button size="sm" onClick={saveExisting} disabled={!editingData.name || !editingData.data_source_id}>Save</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing && widgets.some((w) => w.id === editing.id)
|
||||
? "Edit Widget"
|
||||
: "New Widget"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editing && (
|
||||
<WidgetForm
|
||||
value={editing}
|
||||
onChange={setEditing}
|
||||
sources={sources}
|
||||
/>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={
|
||||
!editing?.name ||
|
||||
!editing.data_source_id
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting != null}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete widget?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove this widget. Layout references will
|
||||
become dangling.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{previewing != null && (
|
||||
<WidgetPreviewDialog
|
||||
widgetId={previewing}
|
||||
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
|
||||
onClose={() => setPreviewing(null)}
|
||||
/>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WidgetPreviewDialog({
|
||||
widgetId,
|
||||
widgetName,
|
||||
onClose,
|
||||
}: {
|
||||
widgetId: number
|
||||
widgetName: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
function WidgetPreviewInline({ widgetId }: { widgetId: number }) {
|
||||
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
|
||||
|
||||
if (isLoading) return <p className="text-muted-foreground text-sm">Loading...</p>
|
||||
if (isError) return <p className="text-muted-foreground text-sm">No data yet</p>
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview: {widgetName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
{isLoading && (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No data yet — widget hasn't been polled
|
||||
</p>
|
||||
)}
|
||||
{data && (
|
||||
<pre className="bg-muted rounded-md p-3 text-xs">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<pre className="bg-muted rounded-md p-3 text-xs">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -312,7 +258,7 @@ function WidgetForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
@@ -327,14 +273,10 @@ function WidgetForm({
|
||||
value={value.display_hint.kind}
|
||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISPLAY_HINT_KINDS.map((h) => (
|
||||
<SelectItem key={h} value={h}>
|
||||
{h}
|
||||
</SelectItem>
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -346,9 +288,7 @@ function WidgetForm({
|
||||
value={value.display_hint.h_align}
|
||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
@@ -362,9 +302,7 @@ function WidgetForm({
|
||||
value={value.display_hint.v_align}
|
||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">Top</SelectItem>
|
||||
<SelectItem value="middle">Middle</SelectItem>
|
||||
@@ -379,14 +317,10 @@ function WidgetForm({
|
||||
value={String(value.data_source_id)}
|
||||
onValueChange={(v) => set("data_source_id", Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{sources.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -412,26 +346,18 @@ function WidgetForm({
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={m.source_path}
|
||||
onChange={(e) =>
|
||||
updateMapping(i, { ...m, source_path: e.target.value })
|
||||
}
|
||||
onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })}
|
||||
placeholder="$.path.to.value"
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">→</span>
|
||||
<span className="text-muted-foreground text-sm">→</span>
|
||||
<Input
|
||||
value={m.target_key}
|
||||
onChange={(e) =>
|
||||
updateMapping(i, { ...m, target_key: e.target.value })
|
||||
}
|
||||
onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
|
||||
placeholder="target_key"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMapping(i)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user