feat: custom widgets (#5889)
This commit is contained in:
1 parent
48e1844b5b
commit
aecbca2b81
69 files changed
+14620
-142
No files matched your search
@@ -0,0 +1,19 @@
|
||||
import { Alert, Anchor, Text } from "@mantine/core";
|
||||
import { IconFlask } from "@tabler/icons-react";
|
||||
|
||||
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/5916";
|
||||
|
||||
export function CustomWidgetBetaBanner() {
|
||||
return (
|
||||
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
|
||||
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
|
||||
|
||||
## Your Task
|
||||
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
|
||||
|
||||
## Output Format
|
||||
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
|
||||
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
|
||||
- If your environment supports structured/JSON output mode, use it.
|
||||
|
||||
## JSON Schema
|
||||
The output must conform to this JSON Schema:
|
||||
|
||||
`;
|
||||
|
||||
const PROMPT_RULES = `
|
||||
|
||||
## Key Rules
|
||||
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
|
||||
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
|
||||
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
|
||||
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
|
||||
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
|
||||
- For arrays of items, inspect the API response to find the correct paths
|
||||
|
||||
## Display Type Guide
|
||||
- \`singleValue\`: Show one prominent number/text (e.g. total count, status)
|
||||
- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array
|
||||
- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers
|
||||
- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63")
|
||||
- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc.
|
||||
- \`statusIndicator\`: Green/red dots based on value matching — for service health checks
|
||||
- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors
|
||||
- \`raw\`: Raw JSON display — for debugging or complex data
|
||||
- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions
|
||||
- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`).
|
||||
|
||||
## Custom JSX (\`customJsx\`) — Available Mantine Components
|
||||
When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below.
|
||||
|
||||
### Layout Components
|
||||
- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\`
|
||||
- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean)
|
||||
- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\`
|
||||
- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\`
|
||||
- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\`
|
||||
- **Center** — Centers content. Props: \`inline\` (boolean)
|
||||
- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width)
|
||||
- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean)
|
||||
- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9)
|
||||
|
||||
### Typography Components
|
||||
- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span)
|
||||
- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\`
|
||||
- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block)
|
||||
- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[])
|
||||
- **Mark** — Highlighted text background. Props: \`color\`
|
||||
- **Kbd** — Keyboard key display
|
||||
- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\`
|
||||
- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never")
|
||||
- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number)
|
||||
|
||||
### Data Display Components
|
||||
- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\`
|
||||
- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\`
|
||||
- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\`
|
||||
- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\`
|
||||
- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\`
|
||||
- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string)
|
||||
- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\`
|
||||
- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\`
|
||||
- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\`
|
||||
- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content.
|
||||
- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean)
|
||||
- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\`
|
||||
- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\`
|
||||
|
||||
### Feedback Components
|
||||
- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\`
|
||||
- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode)
|
||||
- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean)
|
||||
- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots)
|
||||
|
||||
### Media Components
|
||||
- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill)
|
||||
- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\`
|
||||
- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\`
|
||||
- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\`
|
||||
- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\`
|
||||
- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover)
|
||||
|
||||
### Charts (@mantine/charts)
|
||||
- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line)
|
||||
- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\`
|
||||
- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\`
|
||||
- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\`
|
||||
- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\`
|
||||
|
||||
### Common Prop Patterns
|
||||
- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers
|
||||
- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4"
|
||||
- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\`
|
||||
- **Conditional rendering:** Use ternaries: \`{condition ? <Text>Yes</Text> : <Text>No</Text>}\`
|
||||
- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\`
|
||||
|
||||
### Interactive Components (built-in, state-managed)
|
||||
These components manage their own internal state — they provide interactivity without event handlers:
|
||||
|
||||
**PaginatedList** — Paginates its children with prev/next buttons.
|
||||
Props: \`pageSize\` (number, default 6)
|
||||
Usage: Wrap a \`.map()\` expression to paginate results.
|
||||
\`\`\`jsx
|
||||
<PaginatedList pageSize={8}>
|
||||
{data.results.map(item =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Text>{item.name}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
\`\`\`
|
||||
|
||||
**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching.
|
||||
TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab)
|
||||
TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text)
|
||||
\`\`\`jsx
|
||||
<TabsContainer defaultTab="overview">
|
||||
<TabPanel value="overview" label="Overview">
|
||||
<Text>{data.description}</Text>
|
||||
</TabPanel>
|
||||
<TabPanel value="stats" label="Statistics">
|
||||
<Text>Total: {data.count}</Text>
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
\`\`\`
|
||||
|
||||
**Collapsible** — Expandable/collapsible section with a title.
|
||||
Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false)
|
||||
\`\`\`jsx
|
||||
<Collapsible title="Details" defaultOpen={true}>
|
||||
<Text>{data.details}</Text>
|
||||
</Collapsible>
|
||||
\`\`\`
|
||||
|
||||
**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress).
|
||||
Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color)
|
||||
\`\`\`jsx
|
||||
<StatBar label="HP" value={45} max={100} color="red" />
|
||||
<StatBar label="ATK" value={80} max={255} color="orange" />
|
||||
\`\`\`
|
||||
|
||||
**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy).
|
||||
Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm)
|
||||
\`\`\`jsx
|
||||
<Group gap="xs">
|
||||
<TypeBadge type="fire" />
|
||||
<TypeBadge type="flying" />
|
||||
</Group>
|
||||
\`\`\`
|
||||
|
||||
**Available bindings in templates:**
|
||||
- \`data\` — the full API response JSON object
|
||||
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers
|
||||
- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\`
|
||||
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\`
|
||||
- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => <Text>{item.name}</Text>)}\`
|
||||
- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\`
|
||||
|
||||
**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch
|
||||
|
||||
**Chart data formats:**
|
||||
- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row
|
||||
- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series
|
||||
- Sparkline: \`data={[1,2,3]}\` — flat number array
|
||||
- NumberFormatter: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
|
||||
|
||||
### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible)
|
||||
API: \`https://pokeapi.co/api/v2/pokemon/charizard\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
|
||||
<Stack gap={4} style={{flex: 1}}>
|
||||
<Title order={3} tt="capitalize">{data.name}</Title>
|
||||
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
|
||||
<Group gap="xs">
|
||||
{data.types.map(t => <TypeBadge type={t.type.name} />)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<TabsContainer defaultTab="stats">
|
||||
<TabPanel value="stats" label="Stats">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{data.stats.map(s =>
|
||||
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
|
||||
)}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel value="abilities" label="Abilities">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{data.abilities.map(a =>
|
||||
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
|
||||
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
<TabPanel value="moves" label="Moves">
|
||||
<Collapsible title="Move list">
|
||||
<Group gap="xs" pt="xs">
|
||||
{data.moves.slice(0, 20).map(m =>
|
||||
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Collapsible>
|
||||
</TabPanel>
|
||||
</TabsContainer>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
### Paginated List Example
|
||||
API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
|
||||
\`\`\`jsx
|
||||
<Stack gap="sm" p="xs">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
|
||||
</Group>
|
||||
<PaginatedList pageSize={10}>
|
||||
{data.results.map((pokemon, i) =>
|
||||
<Card withBorder p="xs" mb="xs">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar
|
||||
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
|
||||
size="lg"
|
||||
radius="sm"
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>
|
||||
\`\`\`
|
||||
|
||||
## API Response
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_YOUR_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const PROMPT_FOOTER = `
|
||||
## Your Request
|
||||
Describe what you want the widget to show:
|
||||
|
||||
`;
|
||||
|
||||
function buildAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const schemaStr = JSON.stringify(jsonSchema, null, 2);
|
||||
const responseSection = rawResponse
|
||||
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: PROMPT_NO_RESPONSE;
|
||||
|
||||
const configSection = currentConfig
|
||||
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
|
||||
: "";
|
||||
|
||||
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
|
||||
}
|
||||
|
||||
interface CopyAiPromptButtonProps {
|
||||
rawResponse?: string | null;
|
||||
currentConfig?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
close();
|
||||
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
|
||||
{!rawResponse && (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("notification.aiPromptNoResponse")}
|
||||
</Text>
|
||||
)}
|
||||
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconToggleLeft,
|
||||
IconToggleRight,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface WidgetRef {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleEnabledMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${widget.name}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
duplicateMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
showSuccessNotification({
|
||||
title: t("action.duplicate"),
|
||||
message: t("notification.duplicated", { name: result.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.duplicate"), message: t("notification.duplicateError") });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
openConfirmModal({
|
||||
title: t("action.delete"),
|
||||
children: t("action.deleteConfirm", { name: widget.name }),
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(
|
||||
{ id: widget.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("action.delete"),
|
||||
message: t("notification.deleted", { name: widget.name }),
|
||||
});
|
||||
void utils.customWidget.all.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="bottom-end" shadow="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
|
||||
<IconDots {...iconProps} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
|
||||
disabled={toggleEnabledMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
disabled={duplicateMutation.isPending}
|
||||
>
|
||||
{t("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
},
|
||||
});
|
||||
|
||||
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const json = JSON.parse(e.target?.result as string);
|
||||
importMutation.mutate(json);
|
||||
} catch {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
loading={importMutation.isPending}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label="Import custom widget"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { IconApi, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
const displayTypeBadgeColors: Record<string, string> = {
|
||||
singleValue: "blue",
|
||||
keyValue: "green",
|
||||
table: "orange",
|
||||
statGrid: "violet",
|
||||
progressBars: "teal",
|
||||
statusIndicator: "cyan",
|
||||
countGrid: "indigo",
|
||||
raw: "gray",
|
||||
actionButton: "red",
|
||||
customJsx: "pink",
|
||||
};
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface CustomWidgetListProps {
|
||||
definitions: RouterOutputs["customWidget"]["all"];
|
||||
}
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (definitions.length === 0) {
|
||||
return (
|
||||
<NoResults
|
||||
icon={IconApi}
|
||||
title={t("page.list.noResults")}
|
||||
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("table.name")}</Table.Th>
|
||||
<Table.Th>{t("table.url")}</Table.Th>
|
||||
<Table.Th>{t("table.display")}</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{def.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
|
||||
{t(`displayType.${def.displayType}` as never)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${def.id}`}
|
||||
variant="filled"
|
||||
color="red"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil {...iconProps} />
|
||||
</ActionIcon>
|
||||
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
|
||||
|
||||
import { displayComponents } from "@homarr/widgets/custom-api/component";
|
||||
|
||||
interface PreviewInput {
|
||||
url: string;
|
||||
method: string;
|
||||
authType: string;
|
||||
headerName?: string;
|
||||
requestBody?: string;
|
||||
displayType: string;
|
||||
displayConfig: Record<string, unknown>;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
definitionId?: string;
|
||||
}
|
||||
|
||||
export interface PreviewFetchResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseInfo: { status: number; statusText: string } | null;
|
||||
rawResponse: string | null;
|
||||
}
|
||||
|
||||
interface CustomWidgetPreviewProps {
|
||||
getFormValues: () => PreviewInput;
|
||||
formValues: PreviewInput;
|
||||
fetchResult: PreviewFetchResult | null;
|
||||
cachedJson: unknown;
|
||||
onTest: () => void;
|
||||
isTesting: boolean;
|
||||
testError?: string | null;
|
||||
}
|
||||
|
||||
export function CustomWidgetPreview({
|
||||
getFormValues,
|
||||
formValues,
|
||||
fetchResult,
|
||||
cachedJson,
|
||||
onTest,
|
||||
isTesting,
|
||||
testError,
|
||||
}: CustomWidgetPreviewProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
const displayData = useMemo((): Record<string, unknown> | null => {
|
||||
const { displayType, displayConfig } = formValues;
|
||||
if (displayType === "actionButton") {
|
||||
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
|
||||
}
|
||||
if (!fetchResult?.success || cachedJson == null) return null;
|
||||
return extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
|
||||
}, [fetchResult?.success, cachedJson, formValues]);
|
||||
|
||||
const handleTest = () => {
|
||||
const values = getFormValues();
|
||||
if (!values.url) return;
|
||||
onTest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={5}>{t("preview.title")}</Title>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
>
|
||||
{t("preview.test")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{testError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{testError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetchResult && !fetchResult.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{fetchResult.error}
|
||||
</Text>
|
||||
{fetchResult.responseInfo && (
|
||||
<Badge size="xs" color="red" variant="light" mt={4}>
|
||||
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) && (
|
||||
<Stack gap="xs">
|
||||
{fetchResult?.success && (
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mih={120}>
|
||||
<PreviewDisplay data={displayData} />
|
||||
</Card>
|
||||
|
||||
{fetchResult?.success && fetchResult.rawResponse && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
onClick={() => {
|
||||
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
window.open(blobUrl);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}}
|
||||
>
|
||||
{t("preview.rawResponse")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!displayData && !testError && !isTesting && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("preview.hint")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDisplay({ data }: { data: unknown }) {
|
||||
const typed = data as Record<string, unknown>;
|
||||
if (!typed || typeof typed !== "object") {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
No data
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const dataType = typed.type as string | undefined;
|
||||
|
||||
if (dataType === "actionButton") {
|
||||
return (
|
||||
<Center p="sm">
|
||||
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
|
||||
{(typed.buttonLabel as string) ?? "Execute"}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const Component = dataType ? displayComponents[dataType] : undefined;
|
||||
if (Component) {
|
||||
return <Component data={typed} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h={100}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { CustomWidgetBetaBanner } from "../../_beta-banner";
|
||||
import { CustomWidgetForm } from "../../_custom-widget-form";
|
||||
|
||||
const authTypeExpectedSecrets: Record<string, string[]> = {
|
||||
bearer: ["apiKey"],
|
||||
basic: ["username", "password"],
|
||||
apiKeyHeader: ["apiKey"],
|
||||
apiKeyQuery: ["apiKey"],
|
||||
};
|
||||
|
||||
function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string; hasValue?: boolean }>) {
|
||||
const expected = authTypeExpectedSecrets[authType] ?? [];
|
||||
const existingKinds = new Set(dbSecrets.map((s) => s.kind));
|
||||
const result = dbSecrets.map((s) => ({ kind: s.kind, value: "", hasValue: s.hasValue ?? false }));
|
||||
for (const kind of expected) {
|
||||
if (!existingKinds.has(kind)) {
|
||||
result.push({ kind, value: "", hasValue: false });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: "",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (
|
||||
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
|
||||
).map((item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
unit: item.unit,
|
||||
color: item.color ?? "blue",
|
||||
})),
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({
|
||||
label: bar.label,
|
||||
valuePath: bar.valuePath,
|
||||
maxPath: bar.maxPath ?? "",
|
||||
unit: bar.unit,
|
||||
color: bar.color ?? "blue",
|
||||
})),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({
|
||||
label: item.label,
|
||||
jsonPath: item.jsonPath,
|
||||
goodValues: item.goodValues.join(", "),
|
||||
}),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template: (config.template as string) ?? "",
|
||||
}),
|
||||
};
|
||||
|
||||
const overrides = typeOverrides[displayType]?.() ?? {};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
interface EditCustomWidgetPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const params = await props.params;
|
||||
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
|
||||
|
||||
const displayConfig = definition.displayConfig as Record<string, unknown>;
|
||||
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, definition.name]])} nonInteractable={["edit"]} />
|
||||
<Container size="xl">
|
||||
<Stack>
|
||||
<Title>{definition.name}</Title>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetForm
|
||||
mode="edit"
|
||||
definitionId={params.id}
|
||||
initialValues={{
|
||||
name: definition.name,
|
||||
description: definition.description ?? "",
|
||||
iconUrl: definition.iconUrl ?? "",
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName ?? "",
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody ?? "",
|
||||
...displayValues,
|
||||
secrets: buildInitialSecrets(definition.authType, definition.secrets),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetBetaBanner } from "../_beta-banner";
|
||||
import { CustomWidgetForm } from "../_custom-widget-form";
|
||||
|
||||
export default async function NewCustomWidgetPage() {
|
||||
const session = await auth();
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect("/manage/custom-widgets");
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container size="xl">
|
||||
<Stack>
|
||||
<div>
|
||||
<Title>{t("page.create.title")}</Title>
|
||||
<Text c="dimmed" size="sm" mt={4}>
|
||||
{t("page.create.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetForm mode="create" />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Group } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user.permissions.includes("admin")) {
|
||||
redirect(session ? "/" : "/auth/login");
|
||||
}
|
||||
|
||||
const definitions = await api.customWidget.all();
|
||||
const t = await getScopedI18n("customWidget");
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetList definitions={definitions} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import {
|
||||
IconAffiliateFilled,
|
||||
IconApi,
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBrandDiscord,
|
||||
@@ -73,6 +74,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconApi,
|
||||
href: "/manage/custom-widgets",
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
@@ -21,6 +22,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
@@ -28,6 +30,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
@@ -52,6 +55,20 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
);
|
||||
}, [items, search]);
|
||||
|
||||
const filteredCustomWidgets = useMemo(
|
||||
() =>
|
||||
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const handleAddCustomWidget = (definitionId: string) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
@@ -113,7 +130,61 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
{filteredCustomWidgets.length > 0 && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? def.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
|
||||
Reference in new issue
Block a user