feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

+63 -286
View File
@@ -1,300 +1,77 @@
import type { HomarrCustomWidgetV2Input } from "./custom-jsx-schema";
export interface CustomJsxExample {
id: string;
title: string;
description: string;
template: string;
requests: readonly Record<string, unknown>[];
widget: HomarrCustomWidgetV2Input;
}
export const CUSTOM_JSX_STARTER = `<Stack gap="sm" p="sm">
<Group justify="space-between"><Text fw={700}>Service status</Text><RefreshButton /></Group>
{status.status?.loading ? <Skeleton height={72} radius="md" /> : status.status?.error ? <Alert color="red">{status.status.error}</Alert> : <Badge color={data.status?.online ? "green" : "red"}>{data.status?.online ? "Online" : "Offline"}</Badge>}
</Stack>`;
export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2Input = {
$schema: "homarr-custom-widget-v2",
name: "New custom widget",
description: "",
sources: { default: { name: "API", baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { status: { path: "/api/status" } },
options: {},
template: CUSTOM_JSX_STARTER,
};
export const customJsxExamples: readonly CustomJsxExample[] = [
{
id: "simple-metric",
title: "Simple metric",
description: "A compact status and metric display with no nested requests.",
template: `<Stack gap="xs" p="sm">
<Group justify="space-between">
<Text fw={600}>{data.name}</Text>
<Badge color={data.status === "online" ? "green" : "red"}>{data.status}</Badge>
</Group>
<Title order={2}>{data.value}</Title>
id: "service-dashboard",
title: "Service dashboard",
description: "A polished responsive dashboard with clear loading and error states.",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Service dashboard",
sources: {
default: { name: "Service API", baseUrl: "https://example.com", networkScope: "public", auth: "none" },
},
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
</Stack>`,
requests: [],
},
},
{
id: "pokedex-detail",
title: "Pokédex detail list",
description: "Paginates the list and fetches details only for visible Pokémon.",
template: `<Stack gap="sm" p="xs">
<Title order={3}>Pokédex</Title>
<PaginatedList pageSize={5}>
{data.results.map((pokemon) =>
<Card key={pokemon.name} withBorder p="xs" mb="xs">
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap={4}>
<Group gap="xs">
{detail.types.map((entry) => <Badge key={entry.type.name} color="red">{entry.type.name}</Badge>)}
</Group>
<Text size="xs">HP {detail.stats[0].base_stat} · HTTP {meta.status}</Text>
</Stack>
}
</SubFetch>
</Card>
)}
</PaginatedList>
id: "search-and-action",
title: "Search and action",
description: "A temporary search input, manual query, and explicit mutation.",
widget: {
$schema: "homarr-custom-widget-v2",
name: "Series search",
sources: {
default: {
name: "Media API",
baseUrl: "https://example.com",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-Api-Key" },
},
},
requests: {
search: { path: "/api/series/search", trigger: "manual", query: { term: { $param: "query" } } },
monitor: {
kind: "action",
method: "POST",
path: "/api/series/{param:id}/monitor",
confirmation: "Monitor this series?",
},
},
options: {},
template: `<Stack gap="sm" p="md">
<TextInput bind="search" label="Search TV shows" placeholder="Start typing…" />
<SubFetch requestId="search" params={{ query: inputs.search }}>
{(results) => <Stack gap="xs">{(results ?? []).map(series => <Paper key={series.id} withBorder p="sm" radius="md"><Group justify="space-between" wrap="nowrap"><Stack gap={2}><Text fw={600}>{series.title}</Text><Text size="xs" c="dimmed">{series.year ?? "Unknown year"}</Text></Stack><ActionButton requestId="monitor" params={{ id: series.id }}>Monitor</ActionButton></Group></Paper>)}</Stack>}
</SubFetch>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 300,
},
],
},
{
id: "stocks-dashboard",
title: "Stocks dashboard",
description: "Responsive portfolio values and chart with parent refresh.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={3}>Portfolio</Title>
<RefreshButton label="Refresh" size="xs" />
</Group>
<SimpleGrid cols={3}>
{data.holdings.map((stock) =>
<Card key={stock.symbol} withBorder p="xs">
<Text fw={700}>{stock.symbol}</Text>
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
</Card>
)}
</SimpleGrid>
<LineChart h={200} data={data.timeline} dataKey="date" series={[{ name: "value", color: "blue" }]} />
</Stack>`,
requests: [],
},
{
id: "navidrome-player",
title: "Navidrome mini-player",
description: "Media controls backed by named actions with explicit permissions.",
template: `<Stack gap="sm" p="sm">
<Group wrap="nowrap">
<Image src={data.albumArt} alt={data.album} w={80} h={80} radius="sm" />
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} lineClamp={1}>{data.title}</Text>
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
<Progress value={data.progress} size="xs" color="red" />
</Stack>
</Group>
<Group justify="center" gap="sm">
<ActionButton requestId="previous" label="Previous" size="xs" variant="subtle" />
<ActionButton requestId="toggle-playback" label={data.playing ? "Pause" : "Play"} icon="play" color="red" invalidate={["parent"]} />
<ActionButton requestId="next" label="Next" size="xs" variant="subtle" />
</Group>
<ToggleSwitch requestId="shuffle" onParams={{ enabled: true }} offParams={{ enabled: false }} initialValue={data.shuffle} label="Shuffle" size="xs" />
</Stack>`,
requests: [
{
id: "previous",
kind: "action",
method: "POST",
pathTemplate: "/api/player/previous",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "toggle-playback",
kind: "action",
method: "POST",
pathTemplate: "/api/player/toggle",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "next",
kind: "action",
method: "POST",
pathTemplate: "/api/player/next",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "shuffle",
kind: "action",
method: "PUT",
pathTemplate: "/api/player/shuffle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
],
},
{
id: "pokedex-interactive",
title: "Interactive Pokédex",
description:
"A full-featured Pokédex with paginated list, detail SubFetch, type badges, stats with progress bars, moves grid, and evolution chain. Exercises filter(Boolean), pop(), find, reduce, sort, replaceAll, padStart, arithmetic, and nested SubFetch.",
template: `<Stack gap="sm" p="sm" style={{ minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">P</ThemeIcon>
<Box>
<Title order={3}>Pokédex</Title>
<Text size="xs" c="dimmed"><NumberFormatter value={data.count} thousandSeparator /> Pokémon discovered</Text>
</Box>
</Group>
<RefreshButton label="Refresh" size="xs" />
</Group>
<PaginatedList pageSize={5}>
{data.results.map((pokemon, index) =>
<Accordion key={pokemon.name} variant="separated" radius="md">
<Accordion.Item value={pokemon.name}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="sm" radius="md" src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + (index + 1) + ".png"} />
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed">#{String(index + 1).padStart(4, "0")}</Text>
<Text fw={700} tt="capitalize" lineClamp={1}>{pokemon.name.replaceAll("-", " ")}</Text>
</Box>
</Group>
<Badge size="xs" variant="outline">Details</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
{(detail, meta) =>
<Stack gap="sm">
<Grid gutter="sm">
<Grid.Col span={{ base: 12, sm: 5 }}>
<Center>
<Image src={detail.sprites.front_default} alt={detail.name} fit="contain" h={120} />
</Center>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 7 }}>
<Stack gap="xs">
<Group gap="xs">
{detail.types.map((entry) =>
<Badge key={entry.type.name} tt="capitalize">{entry.type.name}</Badge>
)}
</Group>
<SimpleGrid cols={3} spacing="xs">
<Box><Text size="xs" c="dimmed">Height</Text><Text fw={700}><NumberFormatter value={detail.height / 10} decimalScale={1} suffix=" m" /></Text></Box>
<Box><Text size="xs" c="dimmed">Weight</Text><Text fw={700}><NumberFormatter value={detail.weight / 10} decimalScale={1} suffix=" kg" /></Text></Box>
<Box><Text size="xs" c="dimmed">Base XP</Text><Text fw={700}>{detail.base_experience || "—"}</Text></Box>
</SimpleGrid>
<Group gap="xs">
{detail.abilities.map((entry) =>
<Badge key={entry.ability.name} variant="light" size="xs" tt="capitalize">
{entry.ability.name.replaceAll("-", " ")}{entry.is_hidden ? " ·H" : ""}
</Badge>
)}
</Group>
</Stack>
</Grid.Col>
</Grid>
<Tabs defaultValue="stats" variant="outline" radius="md">
<Tabs.List>
<Tabs.Tab value="stats">Stats</Tabs.Tab>
<Tabs.Tab value="moves">Moves</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats" pt="sm">
<Stack gap="xs">
{detail.stats.map((entry) =>
<Box key={entry.stat.name}>
<Group justify="space-between" mb={2}>
<Text size="xs" tt="capitalize">{entry.stat.name.replaceAll("-", " ")}</Text>
<Text size="xs" fw={700}>{entry.base_stat}</Text>
</Group>
<Progress value={Math.min(100, entry.base_stat / 2.55)} size="sm" radius="xl" />
</Box>
)}
<Group justify="space-between">
<Text size="sm" fw={700}>Total</Text>
<Badge>{detail.stats.reduce((sum, s) => sum + s.base_stat, 0)}</Badge>
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="moves" pt="sm">
<ScrollArea h={120} type="auto">
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="xs">
{detail.moves.slice(0, 30).map((entry) =>
<Badge key={entry.move.name} variant="light" size="xs" tt="capitalize" style={{ whiteSpace: "normal" }}>
{entry.move.name.replaceAll("-", " ")}
</Badge>
)}
</SimpleGrid>
</ScrollArea>
</Tabs.Panel>
</Tabs>
<Text size="xs" c="dimmed">Status: {meta.status}</Text>
</Stack>
}
</SubFetch>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
</PaginatedList>
</Stack>`,
requests: [
{
id: "pokemon-detail",
kind: "query",
method: "GET",
pathTemplate: "/api/v2/pokemon/{name}",
parameters: { name: "string" },
auth: "none",
minimumBoardPermission: "view",
cacheTtlSeconds: 3600,
},
],
},
{
id: "smart-home-control",
title: "Smart-home control",
description: "A stateful light control with an explicit destructive confirmation.",
template: `<Stack gap="md" p="sm">
<Group justify="space-between">
<Title order={4}>Living room</Title>
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
</Group>
<ToggleSwitch requestId="set-light" onParams={{ state: "on" }} offParams={{ state: "off" }} initialValue={data.state === "on"} label="Power" color="yellow" />
<ActionButton requestId="delete-scene" label="Delete scene" color="red" icon="trash" confirmMessage="Delete this scene?" />
</Stack>`,
requests: [
{
id: "set-light",
kind: "action",
method: "PUT",
pathTemplate: "/api/lights/living-room",
parameters: { state: "string" },
bodyTemplate: { state: { $param: "state" } },
auth: "inherit",
minimumBoardPermission: "modify",
},
{
id: "delete-scene",
kind: "action",
method: "DELETE",
pathTemplate: "/api/scenes/living-room",
parameters: {},
auth: "inherit",
minimumBoardPermission: "full",
},
],
},
},
];