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 { useMemo, useRef } from "react"
|
||||||
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
|
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
|
||||||
import { computeLayout } from "@/lib/layout-engine"
|
import { computeLayout } from "@/lib/layout-engine"
|
||||||
|
import { useWidgetPreview } from "@/api/widgets"
|
||||||
|
|
||||||
interface LayoutPreviewProps {
|
interface LayoutPreviewProps {
|
||||||
layout: LayoutNode
|
layout: LayoutNode
|
||||||
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
|
|||||||
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
|
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({
|
export function LayoutPreview({
|
||||||
layout,
|
layout,
|
||||||
screenWidth,
|
screenWidth,
|
||||||
@@ -54,11 +140,6 @@ export function LayoutPreview({
|
|||||||
const box = bounds.get(wid)
|
const box = bounds.get(wid)
|
||||||
if (!box) return null
|
if (!box) return null
|
||||||
const w = widgets.find((w) => w.id === wid)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={wid}
|
key={wid}
|
||||||
@@ -70,35 +151,11 @@ export function LayoutPreview({
|
|||||||
height: box.height * scale,
|
height: box.height * scale,
|
||||||
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
border: `1px solid ${colorToCSS(theme.secondary)}`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: flexAlign,
|
|
||||||
justifyContent: flexJustify,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
padding: 2 * scale,
|
padding: 2 * scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
useDataSources,
|
useDataSources,
|
||||||
useCreateDataSource,
|
useCreateDataSource,
|
||||||
@@ -14,13 +14,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -30,28 +23,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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 { 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"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const SOURCE_TYPES: SourceType[] = [
|
const SOURCE_TYPES: SourceType[] = [
|
||||||
"weather",
|
"weather", "media", "rss", "http_json", "webhook", "clock", "static_text",
|
||||||
"media",
|
|
||||||
"rss",
|
|
||||||
"http_json",
|
|
||||||
"webhook",
|
|
||||||
"clock",
|
|
||||||
"static_text",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
|
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: [] },
|
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() {
|
export function DataSourcesPage() {
|
||||||
const { data: sources = [], isLoading } = useDataSources()
|
const { data: sources = [], isLoading } = useDataSources()
|
||||||
const create = useCreateDataSource()
|
const create = useCreateDataSource()
|
||||||
const update = useUpdateDataSource()
|
const update = useUpdateDataSource()
|
||||||
const del = useDeleteDataSource()
|
const del = useDeleteDataSource()
|
||||||
|
|
||||||
const [editing, setEditing] = useState<DataSource | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [deleting, setDeleting] = 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() {
|
function openNew() {
|
||||||
const nextId =
|
const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
|
||||||
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
|
setNewSource({ ...EMPTY, id: nextId })
|
||||||
setEditing({ ...EMPTY, id: nextId })
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(ds: DataSource) {
|
function startEdit(ds: DataSource) {
|
||||||
setEditing({ ...ds })
|
setEditingId(ds.id)
|
||||||
|
setEditingData({ ...ds })
|
||||||
|
setNewSource(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function cancelEdit() {
|
||||||
if (!editing) return
|
setEditingId(null)
|
||||||
const isNew = !sources.some((s) => s.id === editing.id)
|
setEditingData(null)
|
||||||
|
setNewSource(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExisting() {
|
||||||
|
if (!editingData) return
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
await update.mutateAsync(editingData)
|
||||||
await create.mutateAsync(editing)
|
toast.success("Data source updated")
|
||||||
toast.success("Data source created")
|
setEditingId(null)
|
||||||
} else {
|
setEditingData(null)
|
||||||
await update.mutateAsync(editing)
|
|
||||||
toast.success("Data source updated")
|
|
||||||
}
|
|
||||||
setEditing(null)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function saveNew() {
|
||||||
if (deleting == null) return
|
if (!newSource) return
|
||||||
try {
|
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")
|
toast.success("Data source deleted")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
|
||||||
Data Sources
|
<p className="text-muted-foreground text-sm">Configure data feeds</p>
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Configure external data feeds
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openNew}>
|
<Button onClick={openNew} disabled={!!newSource}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Source
|
Add Source
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sources.length === 0 ? (
|
<div className="grid gap-3">
|
||||||
<Card>
|
{newSource && (
|
||||||
<CardContent className="py-12 text-center">
|
<Card className="border-primary">
|
||||||
<p className="text-muted-foreground">
|
<CardHeader className="py-3">
|
||||||
No data sources configured yet.
|
<CardTitle className="text-base">New Data Source</CardTitle>
|
||||||
</p>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent className="space-y-4">
|
||||||
</Card>
|
<DataSourceForm value={newSource} onChange={setNewSource} />
|
||||||
) : (
|
<div className="flex gap-2 justify-end">
|
||||||
<div className="grid gap-3">
|
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
|
||||||
{sources.map((ds) => (
|
<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}>
|
<Card key={ds.id}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
|
|||||||
<Badge variant="secondary">{ds.source_type}</Badge>
|
<Badge variant="secondary">{ds.source_type}</Badge>
|
||||||
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
|
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
|
||||||
{ds.config.type === "external" && ds.config.url && (
|
{ds.config.type === "external" && ds.config.url && (
|
||||||
<span className="text-muted-foreground max-w-xs truncate text-xs">
|
<span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
|
||||||
{ds.config.url}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
|
||||||
variant="ghost"
|
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||||
size="icon"
|
|
||||||
onClick={() => openEdit(ds)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeleting(ds.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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>
|
</Card>
|
||||||
))}
|
)
|
||||||
</div>
|
})}
|
||||||
)}
|
</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({
|
function HeaderRow({
|
||||||
headerKey,
|
headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
|
||||||
headerValue,
|
|
||||||
onChangeKey,
|
|
||||||
onChangeValue,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
}: {
|
||||||
headerKey: string
|
headerKey: string
|
||||||
headerValue: string
|
headerValue: string
|
||||||
@@ -268,12 +268,7 @@ function HeaderRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
|
||||||
value={headerKey}
|
|
||||||
onChange={(e) => onChangeKey(e.target.value)}
|
|
||||||
placeholder="key"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
type={sensitive && !visible ? "password" : "text"}
|
type={sensitive && !visible ? "password" : "text"}
|
||||||
@@ -283,17 +278,8 @@ function HeaderRow({
|
|||||||
className={sensitive ? "pr-9" : ""}
|
className={sensitive ? "pr-9" : ""}
|
||||||
/>
|
/>
|
||||||
{sensitive && (
|
{sensitive && (
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
|
||||||
variant="ghost"
|
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,13 +290,28 @@ function HeaderRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataSourceForm({
|
function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
|
||||||
value,
|
const [preview, setPreview] = useState("")
|
||||||
onChange,
|
|
||||||
}: {
|
useEffect(() => {
|
||||||
value: DataSource
|
setPreview(formatClockPreview(format, timezone))
|
||||||
onChange: (ds: DataSource) => void
|
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]) =>
|
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
|
||||||
onChange({ ...value, [k]: v })
|
onChange({ ...value, [k]: v })
|
||||||
|
|
||||||
@@ -326,29 +327,18 @@ function DataSourceForm({
|
|||||||
const isStaticText = value.config.type === "static_text"
|
const isStaticText = value.config.type === "static_text"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input
|
<Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
|
||||||
value={value.name}
|
|
||||||
onChange={(e) => set("name", e.target.value)}
|
|
||||||
placeholder="e.g. weather"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Source Type</Label>
|
<Label>Source Type</Label>
|
||||||
<Select
|
<Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
|
||||||
value={value.source_type}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SOURCE_TYPES.map((t) => (
|
{SOURCE_TYPES.map((t) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||||
{t}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -358,20 +348,11 @@ function DataSourceForm({
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>URL</Label>
|
<Label>URL</Label>
|
||||||
<Input
|
<Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
|
||||||
value={value.config.url ?? ""}
|
|
||||||
onChange={(e) => setConfig({ url: e.target.value || null })}
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>API Key</Label>
|
<Label>API Key</Label>
|
||||||
<Input
|
<Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
|
||||||
type="password"
|
|
||||||
value={value.config.api_key ?? ""}
|
|
||||||
onChange={(e) => setConfig({ api_key: e.target.value || null })}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -380,55 +361,33 @@ function DataSourceForm({
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Format</Label>
|
<Label>Format</Label>
|
||||||
<Input
|
<Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
|
||||||
value={value.config.format}
|
|
||||||
onChange={(e) => setConfig({ format: e.target.value })}
|
|
||||||
placeholder="%H:%M:%S"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Timezone</Label>
|
<Label>Timezone</Label>
|
||||||
<Input
|
<Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
|
||||||
value={value.config.timezone}
|
|
||||||
onChange={(e) => setConfig({ timezone: e.target.value })}
|
|
||||||
placeholder="Europe/Warsaw"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isStaticText && (
|
{isStaticText && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Text</Label>
|
<Label>Text</Label>
|
||||||
<Input
|
<Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
|
||||||
value={value.config.text}
|
|
||||||
onChange={(e) => setConfig({ text: e.target.value })}
|
|
||||||
placeholder="Hello world"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Poll Interval (seconds)</Label>
|
<Label>Poll Interval (seconds)</Label>
|
||||||
<Input
|
<Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
|
||||||
type="number"
|
|
||||||
value={value.poll_interval_secs}
|
|
||||||
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExternal && (
|
{isExternal && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Headers</Label>
|
<Label>Headers</Label>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setConfig({ headers: [...value.config.headers, ["", ""]] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -448,9 +407,7 @@ function DataSourceForm({
|
|||||||
next[i] = [k, newVal]
|
next[i] = [k, newVal]
|
||||||
setConfig({ headers: next })
|
setConfig({ headers: next })
|
||||||
}}
|
}}
|
||||||
onRemove={() =>
|
onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })}
|
||||||
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,16 +18,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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 { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
|
|||||||
onAddContainer={(path, dir) =>
|
onAddContainer={(path, dir) =>
|
||||||
addChild(path, makeContainerChild(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)}
|
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||||
isRoot={selected.length === 0}
|
isRoot={selected.length === 0}
|
||||||
widgets={widgets}
|
widgets={widgets}
|
||||||
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
|
|||||||
<LeafProps
|
<LeafProps
|
||||||
path={selected}
|
path={selected}
|
||||||
widgetId={selectedNode?.widget_id ?? 0}
|
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)}
|
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
|
||||||
widgets={widgets}
|
widgets={widgets}
|
||||||
sizing={
|
sizing={
|
||||||
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 && (
|
{showPreview && root && theme && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -493,7 +466,10 @@ function ContainerProps({
|
|||||||
onUpdateProp,
|
onUpdateProp,
|
||||||
onAddWidget,
|
onAddWidget,
|
||||||
onAddContainer,
|
onAddContainer,
|
||||||
onRemove,
|
pendingDelete,
|
||||||
|
onRequestDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
onUpdateSizing,
|
onUpdateSizing,
|
||||||
isRoot,
|
isRoot,
|
||||||
widgets,
|
widgets,
|
||||||
@@ -503,7 +479,10 @@ function ContainerProps({
|
|||||||
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", 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
|
onAddWidget: (path: Path, widgetId: number) => void
|
||||||
onAddContainer: (path: Path, direction: Direction) => void
|
onAddContainer: (path: Path, direction: Direction) => void
|
||||||
onRemove: () => void
|
pendingDelete: Path | null
|
||||||
|
onRequestDelete: () => void
|
||||||
|
onConfirmDelete: () => void
|
||||||
|
onCancelDelete: () => void
|
||||||
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
||||||
isRoot: boolean
|
isRoot: boolean
|
||||||
widgets: { id: number; name: string }[]
|
widgets: { id: number; name: string }[]
|
||||||
@@ -622,14 +601,37 @@ function ContainerProps({
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{pendingDelete !== null &&
|
||||||
variant="destructive"
|
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
|
||||||
size="sm"
|
<div className="grid gap-2">
|
||||||
onClick={onRemove}
|
<p className="text-sm text-destructive">
|
||||||
>
|
{isRoot
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
? "Clear entire layout? You can rebuild afterward."
|
||||||
{isRoot ? "Clear Layout" : "Remove"}
|
: "Remove this node and all its children?"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -637,14 +639,20 @@ function ContainerProps({
|
|||||||
function LeafProps({
|
function LeafProps({
|
||||||
path,
|
path,
|
||||||
widgetId,
|
widgetId,
|
||||||
onRemove,
|
pendingDelete,
|
||||||
|
onRequestDelete,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
onUpdateSizing,
|
onUpdateSizing,
|
||||||
widgets,
|
widgets,
|
||||||
sizing,
|
sizing,
|
||||||
}: {
|
}: {
|
||||||
path: Path
|
path: Path
|
||||||
widgetId: number
|
widgetId: number
|
||||||
onRemove: () => void
|
pendingDelete: Path | null
|
||||||
|
onRequestDelete: () => void
|
||||||
|
onConfirmDelete: () => void
|
||||||
|
onCancelDelete: () => void
|
||||||
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
|
||||||
widgets: { id: number; name: string }[]
|
widgets: { id: number; name: string }[]
|
||||||
sizing?: LayoutChild["sizing"]
|
sizing?: LayoutChild["sizing"]
|
||||||
@@ -657,10 +665,31 @@ function LeafProps({
|
|||||||
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
|
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
|
||||||
</div>
|
</div>
|
||||||
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
|
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
|
||||||
<Button variant="destructive" size="sm" onClick={onRemove}>
|
{pendingDelete !== null &&
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
|
||||||
Remove
|
<div className="grid gap-2">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,26 +15,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} 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 { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
|
||||||
import { Save, Upload, Trash2 } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export function PresetsPage() {
|
export function PresetsPage() {
|
||||||
@@ -45,22 +27,22 @@ export function PresetsPage() {
|
|||||||
const loadPreset = useLoadPreset()
|
const loadPreset = useLoadPreset()
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveName, setSaveName] = useState("")
|
const [presetName, setPresetName] = useState("")
|
||||||
const [deleting, setDeleting] = useState<number | null>(null)
|
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
|
||||||
|
|
||||||
async function saveAsPreset() {
|
async function saveAsPreset() {
|
||||||
if (!layout || !saveName) return
|
if (!layout || !presetName) return
|
||||||
const nextId =
|
const nextId =
|
||||||
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
|
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
|
||||||
try {
|
try {
|
||||||
await createPreset.mutateAsync({
|
await createPreset.mutateAsync({
|
||||||
id: nextId,
|
id: nextId,
|
||||||
name: saveName,
|
name: presetName,
|
||||||
layout,
|
layout,
|
||||||
})
|
})
|
||||||
toast.success("Preset saved")
|
toast.success("Preset saved")
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
setSaveName("")
|
setPresetName("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
@@ -75,15 +57,14 @@ export function PresetsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete(id: number) {
|
||||||
if (deleting == null) return
|
|
||||||
try {
|
try {
|
||||||
await deletePreset.mutateAsync(deleting)
|
await deletePreset.mutateAsync(id)
|
||||||
toast.success("Preset deleted")
|
toast.success("Preset deleted")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
setDeleting(null)
|
setConfirmingDelete(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeCount(node: Preset["layout"]["root"]): number {
|
function nodeCount(node: Preset["layout"]["root"]): number {
|
||||||
@@ -104,12 +85,50 @@ export function PresetsPage() {
|
|||||||
Save and restore layout configurations
|
Save and restore layout configurations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setSaving(true)} disabled={!layout}>
|
<Button
|
||||||
<Save className="mr-2 h-4 w-4" />
|
onClick={() => setSaving((v) => !v)}
|
||||||
Save Current Layout
|
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>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{presets.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
@@ -128,69 +147,47 @@ export function PresetsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
|
{confirmingDelete === p.id ? (
|
||||||
<Upload className="mr-1 h-3 w-3" />
|
<>
|
||||||
Load
|
<span className="text-sm text-muted-foreground self-center mr-1">
|
||||||
</Button>
|
Delete?
|
||||||
<Button
|
</span>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="destructive"
|
||||||
onClick={() => setDeleting(p.id)}
|
size="sm"
|
||||||
>
|
onClick={() => confirmDelete(p.id)}
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -32,18 +25,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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 { 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"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
|
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
|
||||||
@@ -64,52 +47,72 @@ export function WidgetsPage() {
|
|||||||
const update = useUpdateWidget()
|
const update = useUpdateWidget()
|
||||||
const del = useDeleteWidget()
|
const del = useDeleteWidget()
|
||||||
|
|
||||||
const [editing, setEditing] = useState<Widget | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [deleting, setDeleting] = useState<number | null>(null)
|
const [editingData, setEditingData] = useState<Widget | null>(null)
|
||||||
const [previewing, setPreviewing] = useState<number | 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() {
|
function openNew() {
|
||||||
const nextId =
|
const nextId =
|
||||||
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
|
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
|
||||||
setEditing({
|
setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 })
|
||||||
...EMPTY,
|
setEditingId(null)
|
||||||
id: nextId,
|
|
||||||
data_source_id: sources[0]?.id ?? 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function startEdit(w: Widget) {
|
||||||
if (!editing) return
|
setEditingId(w.id)
|
||||||
const isNew = !widgets.some((w) => w.id === editing.id)
|
setEditingData({ ...w })
|
||||||
|
setNewWidget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingData(null)
|
||||||
|
setNewWidget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExisting() {
|
||||||
|
if (!editingData) return
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
await update.mutateAsync(editingData)
|
||||||
await create.mutateAsync(editing)
|
toast.success("Widget updated")
|
||||||
toast.success("Widget created")
|
setEditingId(null)
|
||||||
} else {
|
setEditingData(null)
|
||||||
await update.mutateAsync(editing)
|
|
||||||
toast.success("Widget updated")
|
|
||||||
}
|
|
||||||
setEditing(null)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function saveNew() {
|
||||||
if (deleting == null) return
|
if (!newWidget) return
|
||||||
try {
|
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")
|
toast.success("Widget deleted")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
}
|
}
|
||||||
setDeleting(null)
|
setConfirmingDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview(id: number) {
|
||||||
|
setPreviewingId(previewingId === id ? null : id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceName = (id: number) =>
|
const sourceName = (id: number) =>
|
||||||
sources.find((s) => s.id === id)?.name ?? `#${id}`
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -120,21 +123,42 @@ export function WidgetsPage() {
|
|||||||
Display primitives bound to data sources
|
Display primitives bound to data sources
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openNew}>
|
<Button onClick={openNew} disabled={!!newWidget}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Widget
|
Add Widget
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{widgets.length === 0 ? (
|
<div className="grid gap-3">
|
||||||
<Card>
|
{newWidget && (
|
||||||
<CardContent className="py-12 text-center">
|
<Card className="border-primary">
|
||||||
<p className="text-muted-foreground">No widgets configured yet.</p>
|
<CardHeader className="py-3">
|
||||||
</CardContent>
|
<CardTitle className="text-base">New Widget</CardTitle>
|
||||||
</Card>
|
</CardHeader>
|
||||||
) : (
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-3">
|
<WidgetForm value={newWidget} onChange={setNewWidget} sources={sources} />
|
||||||
{widgets.map((w) => (
|
<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}>
|
<Card key={w.id}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -142,143 +166,65 @@ export function WidgetsPage() {
|
|||||||
<CardDescription className="flex items-center gap-2">
|
<CardDescription className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">{w.display_hint.kind}</Badge>
|
<Badge variant="secondary">{w.display_hint.kind}</Badge>
|
||||||
<span>source: {sourceName(w.data_source_id)}</span>
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
|
||||||
variant="ghost"
|
{isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
size="icon"
|
|
||||||
onClick={() => setPreviewing(w.id)}
|
|
||||||
title="Preview current data"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
|
||||||
variant="ghost"
|
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||||
size="icon"
|
|
||||||
onClick={() => setEditing({ ...w })}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeleting(w.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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>
|
</Card>
|
||||||
))}
|
)
|
||||||
</div>
|
})}
|
||||||
)}
|
</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({
|
function WidgetPreviewInline({ widgetId }: { widgetId: number }) {
|
||||||
widgetId,
|
|
||||||
widgetName,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
widgetId: number
|
|
||||||
widgetName: string
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
|
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 (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
<pre className="bg-muted rounded-md p-3 text-xs">
|
||||||
<DialogContent>
|
{JSON.stringify(data, null, 2)}
|
||||||
<DialogHeader>
|
</pre>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +258,7 @@ function WidgetForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Name</Label>
|
<Label>Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -327,14 +273,10 @@ function WidgetForm({
|
|||||||
value={value.display_hint.kind}
|
value={value.display_hint.kind}
|
||||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
|
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DISPLAY_HINT_KINDS.map((h) => (
|
{DISPLAY_HINT_KINDS.map((h) => (
|
||||||
<SelectItem key={h} value={h}>
|
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||||
{h}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -346,9 +288,7 @@ function WidgetForm({
|
|||||||
value={value.display_hint.h_align}
|
value={value.display_hint.h_align}
|
||||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
|
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="left">Left</SelectItem>
|
<SelectItem value="left">Left</SelectItem>
|
||||||
<SelectItem value="center">Center</SelectItem>
|
<SelectItem value="center">Center</SelectItem>
|
||||||
@@ -362,9 +302,7 @@ function WidgetForm({
|
|||||||
value={value.display_hint.v_align}
|
value={value.display_hint.v_align}
|
||||||
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
|
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="top">Top</SelectItem>
|
<SelectItem value="top">Top</SelectItem>
|
||||||
<SelectItem value="middle">Middle</SelectItem>
|
<SelectItem value="middle">Middle</SelectItem>
|
||||||
@@ -379,14 +317,10 @@ function WidgetForm({
|
|||||||
value={String(value.data_source_id)}
|
value={String(value.data_source_id)}
|
||||||
onValueChange={(v) => set("data_source_id", Number(v))}
|
onValueChange={(v) => set("data_source_id", Number(v))}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sources.map((s) => (
|
{sources.map((s) => (
|
||||||
<SelectItem key={s.id} value={String(s.id)}>
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
{s.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -412,26 +346,18 @@ function WidgetForm({
|
|||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={m.source_path}
|
value={m.source_path}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })}
|
||||||
updateMapping(i, { ...m, source_path: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="$.path.to.value"
|
placeholder="$.path.to.value"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">→</span>
|
<span className="text-muted-foreground text-sm">→</span>
|
||||||
<Input
|
<Input
|
||||||
value={m.target_key}
|
value={m.target_key}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
|
||||||
updateMapping(i, { ...m, target_key: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="target_key"
|
placeholder="target_key"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeMapping(i)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user