fix: simplify custom widget package workbench
This commit is contained in:
1 parent
9a52a035aa
commit
48042a08ff
37 files changed
+1155
-608
No files matched your search
+1
-1
@@ -14,7 +14,7 @@ export const packageAssistantSdkContext = {
|
||||
services:
|
||||
"useWidgetServices() exposes notify({title,message?,color?}), navigate(href), openAdvanced(), closeAdvanced(), openDetail({title,content:ReactNode}), closeDetail(), confirm({title,message,destructive?}):Promise<boolean>, updateOptions(record):Promise<void>. Use updateOptions from an authored configuration surface. Managed storage is isolated memory in preview. Query results and local options are not part of Assistant context.",
|
||||
server:
|
||||
"Optional server.ts imports @homarr/widget-sdk/server and default-exports defineWidgetServer({handler:async(input,context)=>value}). Declare every handler in manifest.handlers with kind:'query'|'action'|'subscription'|'migration', permission:'view'|'modify'|'full', inputSchema? (JSON Schema), guestAccess?; actions default to modify. fetchWidgetConnection<T>(context,{connection:'slot',path,method?,query?,body?,responseType?}) returns {data,ok,status,contentType,durationMs,updatedAt}. getWidgetConnection<TSettings>(context,{connection,serviceType?}) returns {configuration:{kind,serviceType?,settings?:TSettings,...},secrets:Record<string,string>} only to trusted server code for Node SQL, MQTT, SSH, files or other service clients; never return secrets to browser queries. getWidgetServerContext(context) returns options,installationId,bindingNames,isPreview. callWidgetIntegration(context,{connection,capability,input?}) invokes native adapters; subscribeWidgetIntegration yields native stream events. callWidgetRunner and subscribeWidgetRunner address owner-managed remote runners. Server modules have trusted Node capabilities. Secrets stay in owner-bound connections. Do not invent integration capabilities: use reference packages or declared capability docs.",
|
||||
"Optional server.ts imports @homarr/widget-sdk/server and default-exports defineWidgetServer({handler:async(input,context)=>value}). Declare every handler in manifest.handlers with kind:'query'|'action'|'subscription'|'migration', permission:'view'|'modify'|'full', inputSchema? (JSON Schema), guestAccess?; actions default to modify. fetchWidgetConnection<T>(context,{connection:'slot',path,method?,query?,body?,responseType?}) returns {data,ok,status,contentType,durationMs,updatedAt}. getWidgetConnection<TSettings>(context,{connection,serviceType?}) returns {configuration:{kind,serviceType?,settings?:TSettings,...},secrets:Record<string,string>} only to trusted server code for Node SQL, MQTT, SSH, files or other service clients; never return secrets to browser queries. getWidgetServerContext(context) returns options,installationId,bindingNames,bindingLabels,isPreview. Declare connections with multiple:true for a repeatable group, and integrationKind (for example sonarr) to filter compatible native integrations. getWidgetConnectionGroup(context,slot) returns all {connection,label} members for queries and actions; do not hard-code first/second server slots. callWidgetIntegration(context,{connection,capability,input?}) invokes native adapters; subscribeWidgetIntegration yields native stream events. callWidgetRunner and subscribeWidgetRunner address owner-managed remote runners. Server modules have trusted Node capabilities. Secrets stay in owner-bound connections. Do not invent integration capabilities: use reference packages or declared capability docs.",
|
||||
lifecycle:
|
||||
"Proposals only change the visible draft after explicit review; they never save, trust, run or activate code. The owner saves the draft, reviews dependencies and trust, runs exact-candidate Preview, then explicitly activates the saved release for all placements. SDK actions are simulated in default previews, but arbitrary trusted browser/Node code still executes. A preview session proves compilation/session creation only, not successful UI rendering, live action success or all application behavior. Any source/options/binding edit invalidates preview evidence.",
|
||||
};
|
||||
+60
-33
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Divider, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { Alert, Button, Divider, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
|
||||
|
||||
export function PackageAssistant() {
|
||||
@@ -11,8 +12,10 @@ export function PackageAssistant() {
|
||||
const assistant = useOptionalHomarrAssistant();
|
||||
const [request, setRequest] = useState("");
|
||||
const [opened, setOpened] = useState(false);
|
||||
if (!assistant?.enabled) return null;
|
||||
const [sendFailed, setSendFailed] = useState(false);
|
||||
const send = (intent: string) => {
|
||||
if (!assistant?.enabled || assistant.isRunning) return;
|
||||
setSendFailed(false);
|
||||
assistant.open();
|
||||
if (
|
||||
assistant.sendPrompt(
|
||||
@@ -21,45 +24,69 @@ export function PackageAssistant() {
|
||||
) {
|
||||
setOpened(false);
|
||||
setRequest("");
|
||||
}
|
||||
} else setSendFailed(true);
|
||||
};
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} width={320} withinPortal position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Button size="xs" variant="light" leftSection={<IconSparkles size={14} />} disabled={assistant.isRunning}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={14} />}
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
aria-expanded={opened}
|
||||
>
|
||||
{t("open")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("context")}
|
||||
</Text>
|
||||
<Textarea
|
||||
mx="xs"
|
||||
mb="xs"
|
||||
label={t("request")}
|
||||
placeholder={t("requestPlaceholder")}
|
||||
value={request}
|
||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
<Group px="xs" pb="xs">
|
||||
<Button size="xs" disabled={!request.trim()} onClick={() => send(request)}>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider my="xs" />
|
||||
<Stack gap={4}>
|
||||
<Button variant="subtle" size="xs" justify="start" onClick={() => send(t("improvePrompt"))}>
|
||||
{t("improve")}
|
||||
</Button>
|
||||
<Button variant="subtle" size="xs" justify="start" onClick={() => send(t("advancedPrompt"))}>
|
||||
{t("advanced")}
|
||||
</Button>
|
||||
<Button variant="subtle" size="xs" justify="start" onClick={() => send(t("fixPrompt"))}>
|
||||
{t("fix")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{!assistant?.enabled && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{assistant?.unavailableDescription || t("unavailable")}</Text>
|
||||
<Button component={Link} href="/manage/assistant" variant="light" size="xs">
|
||||
{t("setup")}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">{t("manual")}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{assistant?.enabled && (
|
||||
<>
|
||||
{assistant.isRunning && <Alert mb="xs">{t("busy")}</Alert>}
|
||||
{sendFailed && <Alert color="yellow" mb="xs">{t("sendFailed")}</Alert>}
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("context")}
|
||||
</Text>
|
||||
<Textarea
|
||||
mx="xs"
|
||||
mb="xs"
|
||||
label={t("request")}
|
||||
placeholder={t("requestPlaceholder")}
|
||||
value={request}
|
||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
<Group px="xs" pb="xs">
|
||||
<Button size="xs" disabled={!request.trim() || assistant.isRunning} onClick={() => send(request)}>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider my="xs" />
|
||||
<Stack gap={4}>
|
||||
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("createPrompt"))}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("improvePrompt"))}>
|
||||
{t("improve")}
|
||||
</Button>
|
||||
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("advancedPrompt"))}>
|
||||
{t("advanced")}
|
||||
</Button>
|
||||
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("fixPrompt"))}>
|
||||
{t("fix")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, MultiSelect, Select, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export function PackageBindingPicker({ name, requirement, connections, integrations, bindings, onChange }: {
|
||||
name: string;
|
||||
requirement: CustomWidgetPackage["connections"][string];
|
||||
connections: RouterOutputs["customWidget"]["package"]["connections"];
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
bindings: Record<string, string>;
|
||||
onChange(bindings: Record<string, string>): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const utils = clientApi.useUtils();
|
||||
const saveConnection = clientApi.customWidget.package.saveConnection.useMutation();
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const currentBindings = useRef(bindings);
|
||||
currentBindings.current = bindings;
|
||||
const ids = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]!);
|
||||
const compatible = connections.filter((connection) => isWidgetConnectionCompatible(requirement, connection.configuration));
|
||||
const choices = compatible.map((connection) => ({ value: connection.id, label: connection.name }));
|
||||
if (requirement.kind === "integration") {
|
||||
for (const integration of integrations) {
|
||||
if (requirement.integrationKind && integration.kind !== requirement.integrationKind) continue;
|
||||
if (compatible.some((connection) => connection.integrationId === integration.id)) continue;
|
||||
choices.push({ value: `integration:${integration.id}`, label: integration.name });
|
||||
}
|
||||
}
|
||||
// Keep unavailable bindings visible so a removed integration can be diagnosed or replaced.
|
||||
for (const id of ids) {
|
||||
if (!choices.some((choice) => choice.value === id))
|
||||
choices.push({ value: id, label: connections.find((connection) => connection.id === id)?.name ?? t("unavailableConnection") });
|
||||
}
|
||||
const change = async (values: string[]) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextIds: string[] = [];
|
||||
for (const value of values) {
|
||||
if (!value.startsWith("integration:")) {
|
||||
nextIds.push(value);
|
||||
continue;
|
||||
}
|
||||
const integration = integrations.find((entry) => `integration:${entry.id}` === value);
|
||||
if (!integration) throw new Error(t("unavailableConnection"));
|
||||
const result = await saveConnection.mutateAsync({
|
||||
name: integration.name,
|
||||
integrationId: integration.id,
|
||||
configuration: { kind: "integration" },
|
||||
});
|
||||
nextIds.push(result.id);
|
||||
}
|
||||
await utils.customWidget.package.connections.invalidate();
|
||||
const next = { ...currentBindings.current };
|
||||
for (const key of getWidgetConnectionBindingNames(name, requirement, next)) delete next[key];
|
||||
for (const [index, id] of nextIds.entries()) {
|
||||
let key = name;
|
||||
if (index > 0) key = `${name}:${id}`;
|
||||
next[key] = id;
|
||||
}
|
||||
onChange(next);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
label: requirement.label,
|
||||
description: requirement.description,
|
||||
placeholder: t("chooseConnection"),
|
||||
required: !requirement.optional,
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
disabled: pending,
|
||||
data: choices,
|
||||
nothingFoundMessage: t("noCompatibleConnections"),
|
||||
};
|
||||
return (
|
||||
<Stack gap="xs" flex={1}>
|
||||
{requirement.multiple ? (
|
||||
<MultiSelect {...common} value={ids} onChange={(values) => void change(values)} />
|
||||
) : (
|
||||
<Select {...common} value={ids[0] ?? null} onChange={(value) => void change(value ? [value] : [])} />
|
||||
)}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+59
-56
@@ -2,15 +2,16 @@
|
||||
|
||||
import { ConnectionEditor } from "./_package-connection-editor";
|
||||
import { PackageConnectionDiagnostic } from "./_package-connection-diagnostic";
|
||||
import { PackageBindingPicker } from "./_package-binding-picker";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Paper, Select, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { isRecord } from "@homarr/common";
|
||||
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { getWidgetConnectionBindingNames } from "@homarr/custom-widgets/package";
|
||||
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export function PackageConnections({
|
||||
requirements,
|
||||
@@ -30,6 +31,7 @@ export function PackageConnections({
|
||||
const t = useI18n("customWidget.package");
|
||||
const utils = clientApi.useUtils();
|
||||
const connections = clientApi.customWidget.package.connections.useQuery();
|
||||
const integrations = clientApi.integration.all.useQuery();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [diagnostic, setDiagnostic] = useState<RouterOutputs["customWidget"]["package"]["diagnoseConnection"] | null>(
|
||||
@@ -45,65 +47,66 @@ export function PackageConnections({
|
||||
{Object.keys(requirements).length === 0 && <Alert>{t("noConnections")}</Alert>}
|
||||
{Object.entries(requirements).map(([name, requirement]) => (
|
||||
<Paper key={name} withBorder p="sm">
|
||||
<Group align="end">
|
||||
<Select
|
||||
flex={1}
|
||||
label={requirement.label}
|
||||
description={requirement.description}
|
||||
required={!requirement.optional}
|
||||
clearable
|
||||
searchable
|
||||
value={bindings[name] ?? null}
|
||||
data={(connections.data ?? [])
|
||||
.filter(
|
||||
(connection) =>
|
||||
isRecord(connection.configuration) &&
|
||||
isWidgetConnectionCompatible(requirement, connection.configuration),
|
||||
)
|
||||
.map((connection) => ({ value: connection.id, label: connection.name }))}
|
||||
onChange={(value) => {
|
||||
const next = { ...bindings };
|
||||
delete next[name];
|
||||
if (value) next[name] = value;
|
||||
onChange(next);
|
||||
}}
|
||||
<Stack gap="xs">
|
||||
<PackageBindingPicker
|
||||
name={name}
|
||||
requirement={requirement}
|
||||
connections={connections.data ?? []}
|
||||
integrations={integrations.data ?? []}
|
||||
bindings={bindings}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!bindings[name]}
|
||||
loading={diagnose.isPending}
|
||||
onClick={() => {
|
||||
const id = bindings[name];
|
||||
if (!id) return;
|
||||
setDiagnostic(null);
|
||||
setDiagnosticError("");
|
||||
diagnose.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: setDiagnostic,
|
||||
onError: (error) => setDiagnosticError(error.message),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("diagnose")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={!bindings[name]}
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setEditingId(bindings[name] ?? null);
|
||||
}}
|
||||
>
|
||||
{t("editConnection")}
|
||||
</Button>
|
||||
</Group>
|
||||
{getWidgetConnectionBindingNames(name, requirement, bindings).map((bindingName) => (
|
||||
<Group key={bindingName} gap="xs">
|
||||
<Text size="sm" flex={1}>{connections.data?.find((entry) => entry.id === bindings[bindingName])?.name ?? bindingName}</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
loading={diagnose.isPending}
|
||||
onClick={() => {
|
||||
const id = bindings[bindingName];
|
||||
if (!id) return;
|
||||
setDiagnostic(null);
|
||||
setDiagnosticError("");
|
||||
diagnose.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: setDiagnostic,
|
||||
onError: (error) => setDiagnosticError(error.message),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("diagnose")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setEditingId(bindings[bindingName] ?? null);
|
||||
}}
|
||||
>
|
||||
{t("editConnection")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{diagnostic && <PackageConnectionDiagnostic result={diagnostic} />}
|
||||
{diagnosticError && <Alert color="red">{diagnosticError}</Alert>}
|
||||
{connections.error && <Alert color="red">{connections.error.message}</Alert>}
|
||||
{integrations.error && <Alert color="red">{integrations.error.message}</Alert>}
|
||||
<Group>
|
||||
{Object.values(requirements).some((requirement) => requirement.kind === "integration") && (
|
||||
<Button component={Link} href="/manage/integrations/new" target="_blank" rel="noopener noreferrer" variant="light">
|
||||
{t("createIntegration")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="subtle" onClick={() => { void connections.refetch(); void integrations.refetch(); }}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Group, Text, Tree, useTree } from "@mantine/core";
|
||||
import type { TreeNodeData } from "@mantine/core";
|
||||
import { IconChevronRight, IconCode, IconFolder, IconFolderOpen } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export function PackageFileTree({ files, selected, onSelect }: {
|
||||
files: Record<string, string>;
|
||||
selected: string;
|
||||
onSelect(path: string): void;
|
||||
}) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const data = useMemo(() => {
|
||||
const roots: TreeNodeData[] = [{ value: "/widget.json", label: t("manifestFile") }];
|
||||
for (const path of Object.keys(files).toSorted()) {
|
||||
const parts = path.split("/");
|
||||
let siblings = roots;
|
||||
let prefix = "";
|
||||
for (const [index, label] of parts.entries()) {
|
||||
prefix += label;
|
||||
if (index === parts.length - 1) {
|
||||
siblings.push({ value: path, label });
|
||||
break;
|
||||
}
|
||||
prefix += "/";
|
||||
let folder = siblings.find((node) => node.value === prefix);
|
||||
if (!folder) {
|
||||
folder = { value: prefix, label, children: [] };
|
||||
siblings.push(folder);
|
||||
}
|
||||
siblings = folder.children ?? [];
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}, [files, t]);
|
||||
const tree = useTree({
|
||||
selectedState: [selected],
|
||||
onSelectedStateChange: (values) => {
|
||||
const value = values[0];
|
||||
if (value && !value.endsWith("/")) onSelect(value);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Tree
|
||||
aria-label={t("files")}
|
||||
data={data}
|
||||
tree={tree}
|
||||
levelOffset={16}
|
||||
selectOnClick
|
||||
renderNode={({ node, expanded, hasChildren, elementProps }) => {
|
||||
let Icon = IconCode;
|
||||
if (hasChildren) Icon = IconFolder;
|
||||
if (hasChildren && expanded) Icon = IconFolderOpen;
|
||||
return (
|
||||
<Group {...elementProps} gap={6} wrap="nowrap" py={5} title={node.value}>
|
||||
{hasChildren && <IconChevronRight size={12} style={{ transform: `rotate(${expanded ? 90 : 0}deg)` }} />}
|
||||
<Icon size={16} style={{ flexShrink: 0 }} />
|
||||
<Text size="xs" truncate>
|
||||
{node.label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, Badge, Button, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconPackage, IconUpload } from "@tabler/icons-react";
|
||||
import { Alert, Avatar, Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconPackage, IconPencil, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -12,21 +12,20 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { PackageRowActions } from "./_package-row-actions";
|
||||
|
||||
export function PackageList({ initialData }: { initialData: RouterOutputs["customWidget"]["package"]["list"] }) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const tCommon = useI18n("common");
|
||||
const installations = clientApi.customWidget.package.list.useQuery(undefined, { initialData });
|
||||
const [filter, setFilter] = useState("");
|
||||
const filtered = (installations.data ?? []).filter((entry) =>
|
||||
entry.name.toLowerCase().includes(filter.toLowerCase()),
|
||||
);
|
||||
const entries = installations.data ?? [];
|
||||
return (
|
||||
<Stack>
|
||||
<Text c="dimmed">{t("libraryDescription")}</Text>
|
||||
<TextInput label={t("search")} value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />
|
||||
{filtered.length === 0 && <Alert>{t("empty")}</Alert>}
|
||||
{entries.length === 0 && <NoResults icon={IconPackage} title={t("empty")} action={{ href: "/manage/custom-widgets/packages/new", label: t("create") }} />}
|
||||
<ManageCollectionList ariaLabel={t("title")}>
|
||||
{filtered.map((entry) => {
|
||||
{entries.map((entry) => {
|
||||
let status = t("draftOnly");
|
||||
let color = "gray";
|
||||
if (entry.activeArtifactId) {
|
||||
@@ -39,7 +38,7 @@ export function PackageList({ initialData }: { initialData: RouterOutputs["custo
|
||||
return (
|
||||
<ManageCollectionItem
|
||||
key={entry.id}
|
||||
leading={<IconPackage size={26} stroke={1.4} />}
|
||||
leading={<Avatar size={40} radius="sm" color="pink"><IconPackage size={20} stroke={1.5} /></Avatar>}
|
||||
title={<Text fw={600}>{entry.name}</Text>}
|
||||
badges={
|
||||
<Badge color={color} variant="light">
|
||||
@@ -52,10 +51,14 @@ export function PackageList({ initialData }: { initialData: RouterOutputs["custo
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Button component={Link} href={`/manage/custom-widgets/packages/${entry.id}`} variant="default">
|
||||
{t("openWorkspace")}
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button component={Link} href={`/manage/custom-widgets/packages/${entry.id}`} variant="default" size="sm" leftSection={<IconPencil size={16} stroke={1.5} />}>
|
||||
{tCommon("action.edit")}
|
||||
</Button>
|
||||
<PackageRowActions entry={entry} />
|
||||
</Group>
|
||||
}
|
||||
actionsAlignment="center"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
+87
-73
@@ -8,12 +8,9 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { customWidgetPackageSchema, stringifyWidgetJson } from "@homarr/custom-widgets/package";
|
||||
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
|
||||
|
||||
import { AddPackagePlacement, PackagePlacements } from "./_package-placements";
|
||||
import type { PackageOperation } from "./_package-document";
|
||||
import { PackageWorkshop } from "./_package-workshop";
|
||||
import { PackageTransfer } from "./_package-transfer";
|
||||
import { PackageArtifactReview } from "./_package-artifact-review";
|
||||
import { PackageConvertedPlacements } from "./_package-converted-placements";
|
||||
import { downloadPackage } from "./_package-document";
|
||||
@@ -25,9 +22,12 @@ export function PackageOperations({
|
||||
initialBoardId,
|
||||
source,
|
||||
options,
|
||||
setOptions,
|
||||
dirty,
|
||||
trusted,
|
||||
connectionsDirty,
|
||||
onTrustChange,
|
||||
onSave,
|
||||
saving,
|
||||
onError,
|
||||
onMessage,
|
||||
}: {
|
||||
@@ -35,9 +35,12 @@ export function PackageOperations({
|
||||
initialBoardId?: string;
|
||||
source?: CustomWidgetPackage;
|
||||
options: Record<string, unknown>;
|
||||
setOptions(options: Record<string, unknown>): void;
|
||||
dirty: boolean;
|
||||
trusted: boolean;
|
||||
connectionsDirty: boolean;
|
||||
onTrustChange(trusted: boolean): void;
|
||||
onSave(): void;
|
||||
saving: boolean;
|
||||
onError(error: string): void;
|
||||
onMessage(message: string): void;
|
||||
}) {
|
||||
@@ -88,32 +91,34 @@ export function PackageOperations({
|
||||
};
|
||||
return (
|
||||
<Stack>
|
||||
<Paper withBorder p="md">
|
||||
<Stack>
|
||||
<Text fw={600}>{t("previewOptions")}</Text>
|
||||
{source && <PackageOptionFields schema={source.options} value={options} onChange={setOptions} />}
|
||||
<Button variant="subtle" onClick={() => setOptions({})}>
|
||||
{t("resetOptions")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{!current && <Alert>{t("saveFirst")}</Alert>}
|
||||
<Text size="sm" c="dimmed">{t("useDescription")}</Text>
|
||||
{!current && (
|
||||
<Paper withBorder p="md">
|
||||
<Stack>
|
||||
<Text>{t("saveFirst")}</Text>
|
||||
<Button onClick={onSave} loading={saving}>{t("saveDraft")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
{current && (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack>
|
||||
<Text fw={600}>{t("activation")}</Text>
|
||||
<Text size="sm">{t("activationDescription", { count: current.placements.length })}</Text>
|
||||
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
|
||||
{t("activeArtifact")}: {current.activeArtifactId ?? t("none")}
|
||||
</Text>
|
||||
{dirty && <Alert color="yellow">{t("saveBeforeActivate")}</Alert>}
|
||||
{dirty && (
|
||||
<Alert color="yellow">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{t("saveBeforeActivate")}</Text>
|
||||
<Button size="xs" onClick={onSave} loading={saving}>{t("saveDraft")}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{!dirty && !matchesReviewedSource && <Alert color="yellow">{t("savedSourceChanged")}</Alert>}
|
||||
{inspected.data?.artifact && <PackageArtifactReview artifact={inspected.data.artifact} />}
|
||||
{inspected.error && <Alert color="red">{inspected.error.message}</Alert>}
|
||||
<Checkbox checked={trusted} onChange={(event) => onTrustChange(event.currentTarget.checked)} label={t("trustAcknowledgement")} />
|
||||
<Group>
|
||||
<Button
|
||||
disabled={dirty || !trusted || !source || !matchesReviewedSource}
|
||||
disabled={dirty || connectionsDirty || !trusted || !source || !matchesReviewedSource}
|
||||
loading={activate.isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
@@ -129,50 +134,66 @@ export function PackageOperations({
|
||||
>
|
||||
{t("activate")}
|
||||
</Button>
|
||||
{current.activeArtifactId && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={enable.isPending}
|
||||
onClick={() =>
|
||||
void run(() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }), t("stateSaved"))
|
||||
}
|
||||
>
|
||||
{current.enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!exported.data}
|
||||
onClick={() => {
|
||||
if (exported.data) downloadPackage(current.name, exported.data);
|
||||
}}
|
||||
>
|
||||
{t("exportActive")}
|
||||
</Button>
|
||||
</Group>
|
||||
{current.previousArtifactId && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={rollbackConfirmed}
|
||||
onChange={(event) => setRollbackConfirmed(event.currentTarget.checked)}
|
||||
label={t("rollbackAcknowledgement")}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
disabled={!rollbackConfirmed}
|
||||
loading={rollback.isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => rollback.mutateAsync({ id: current.id, discardNewerWidgetData: true }),
|
||||
t("rolledBack"),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rollback")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Accordion>
|
||||
<Accordion.Item value="release">
|
||||
<Accordion.Control>{t("releaseTools")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack>
|
||||
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
|
||||
{t("activeArtifact")}: {current.activeArtifactId ?? t("none")}
|
||||
</Text>
|
||||
{inspected.data?.artifact && <PackageArtifactReview artifact={inspected.data.artifact} />}
|
||||
{inspected.error && <Alert color="red">{inspected.error.message}</Alert>}
|
||||
<Group>
|
||||
{current.activeArtifactId && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={enable.isPending}
|
||||
onClick={() =>
|
||||
void run(() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }), t("stateSaved"))
|
||||
}
|
||||
>
|
||||
{current.enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!exported.data}
|
||||
onClick={() => {
|
||||
if (exported.data) downloadPackage(current.name, exported.data);
|
||||
}}
|
||||
>
|
||||
{t("exportActive")}
|
||||
</Button>
|
||||
</Group>
|
||||
{current.previousArtifactId && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={rollbackConfirmed}
|
||||
onChange={(event) => setRollbackConfirmed(event.currentTarget.checked)}
|
||||
label={t("rollbackAcknowledgement")}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
disabled={!rollbackConfirmed}
|
||||
loading={rollback.isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => rollback.mutateAsync({ id: current.id, discardNewerWidgetData: true }),
|
||||
t("rolledBack"),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("rollback")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<PackageConvertedPlacements
|
||||
@@ -186,20 +207,13 @@ export function PackageOperations({
|
||||
installation={current}
|
||||
initialBoardId={initialBoardId}
|
||||
source={activeSource.data}
|
||||
initialOptions={options}
|
||||
onAdded={() => void refresh()}
|
||||
onError={onError}
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
)}
|
||||
{activeSource.success && <PackagePlacements installation={current} source={activeSource.data} />}
|
||||
<PackageTransfer
|
||||
id={current.id}
|
||||
name={current.name}
|
||||
active={Boolean(current.activeArtifactId)}
|
||||
dirty={dirty}
|
||||
onError={onError}
|
||||
/>
|
||||
<PackageWorkshop id={current.id} dirty={dirty} />
|
||||
<PackageActivity id={current.id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function AddPackagePlacement({
|
||||
installation,
|
||||
initialBoardId,
|
||||
source,
|
||||
initialOptions,
|
||||
onAdded,
|
||||
onError,
|
||||
onMessage,
|
||||
@@ -23,6 +24,7 @@ export function AddPackagePlacement({
|
||||
installation: Installation;
|
||||
initialBoardId?: string;
|
||||
source: CustomWidgetPackage;
|
||||
initialOptions?: Record<string, unknown>;
|
||||
onAdded(): void;
|
||||
onError(error: string): void;
|
||||
onMessage(message: string): void;
|
||||
@@ -31,7 +33,7 @@ export function AddPackagePlacement({
|
||||
const boards = clientApi.board.getAllBoards.useQuery();
|
||||
const add = clientApi.board.addItem.useMutation();
|
||||
const [boardId, setBoardId] = useState<string | null>(initialBoardId ?? null);
|
||||
const [configuration, setConfiguration] = useState<Record<string, unknown>>({});
|
||||
const [configuration, setConfiguration] = useState<Record<string, unknown>>(initialOptions ?? {});
|
||||
const values = { ...getCustomWidgetDefaultOptions(source.options), ...configuration };
|
||||
const missingConnections = Object.entries(source.connections).filter(
|
||||
([name, requirement]) => !requirement.optional && !installation.bindings[name],
|
||||
|
||||
+45
-67
@@ -1,49 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Group, NumberInput, Select, Stack } from "@mantine/core";
|
||||
import { Accordion, Group, NumberInput, Select, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { usePackageWorkspace } from "./_use-package-workspace";
|
||||
|
||||
const presets = {
|
||||
compact: { width: 320, height: 260 },
|
||||
wide: { width: 640, height: 320 },
|
||||
phone: { width: 390, height: 640 },
|
||||
tablet: { width: 768, height: 540 },
|
||||
};
|
||||
|
||||
export function PackagePreviewControls({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const { width, height, scale, previewTheme, setWidth, setHeight, setScale, setPreviewTheme } = state;
|
||||
const labels = {
|
||||
compact: t("viewportcompact"),
|
||||
wide: t("viewportwide"),
|
||||
phone: t("viewportphone"),
|
||||
tablet: t("viewporttablet"),
|
||||
};
|
||||
let preset = "custom";
|
||||
for (const [name, size] of Object.entries(presets)) {
|
||||
if (size.width === width && size.height === height) preset = name;
|
||||
}
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" grow>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("previewViewport")}
|
||||
value={preset}
|
||||
data={[
|
||||
{ value: "custom", label: t("viewportCustom") },
|
||||
...Object.entries(labels).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
onChange={(value) => {
|
||||
if (!value || !Object.hasOwn(presets, value)) return;
|
||||
const size = presets[value as keyof typeof presets];
|
||||
setWidth(size.width);
|
||||
setHeight(size.height);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("previewTheme")}
|
||||
@@ -58,40 +26,50 @@ export function PackagePreviewControls({ state }: { state: ReturnType<typeof use
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("width")}
|
||||
min={100}
|
||||
max={2400}
|
||||
value={width}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setWidth(value);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("height")}
|
||||
min={100}
|
||||
max={1600}
|
||||
value={height}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setHeight(value);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("previewScale")}
|
||||
min={25}
|
||||
max={200}
|
||||
suffix="%"
|
||||
step={10}
|
||||
value={Math.round(scale * 100)}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setScale(Math.min(2, Math.max(0.25, value / 100)));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Accordion>
|
||||
<Accordion.Item value="dimensions">
|
||||
<Accordion.Control>{t("customViewport")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">{t("customViewportDescription")}</Text>
|
||||
<Group gap="xs" grow>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("width")}
|
||||
min={100}
|
||||
max={2400}
|
||||
value={width}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setWidth(value);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("height")}
|
||||
min={100}
|
||||
max={1600}
|
||||
value={height}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setHeight(value);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("previewScale")}
|
||||
min={25}
|
||||
max={90}
|
||||
suffix="%"
|
||||
step={10}
|
||||
value={Math.round(scale * 100)}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setScale(Math.min(0.9, Math.max(0.25, value / 100)));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Accordion, Alert, Button, Checkbox, Group, Paper, Select, Stack, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Accordion, Alert, Badge, Button, Checkbox, Group, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconCode, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetPreviewFrame } from "@homarr/widgets/preview";
|
||||
|
||||
import { PackagePreviewViewport } from "./_package-preview-viewport";
|
||||
import { PackagePreviewControls } from "./_package-preview-controls";
|
||||
import { PackageRuntimeStatus } from "./_package-runtime-status";
|
||||
import { downloadPackage } from "./_package-document";
|
||||
import type { usePackageWorkspace } from "./_use-package-workspace";
|
||||
import classes from "./_package-workspace.module.css";
|
||||
|
||||
const TrustedWidgetPreview = dynamic(() => import("@homarr/widgets/custom-api/trusted-widget-preview"), { ssr: false });
|
||||
|
||||
export function PackagePreview({
|
||||
state,
|
||||
existing,
|
||||
}: {
|
||||
state: ReturnType<typeof usePackageWorkspace>;
|
||||
existing: boolean;
|
||||
}) {
|
||||
export function PackagePreview({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const [detailsOpen, setDetailsOpen] = useState<string | null>(null);
|
||||
const {
|
||||
parsed,
|
||||
trusted,
|
||||
@@ -49,6 +47,10 @@ export function PackagePreview({
|
||||
previewLoadError,
|
||||
setOptions,
|
||||
} = state;
|
||||
let buildStatus = t("previewNotBuilt");
|
||||
if (previewMutation.isPending) buildStatus = t("previewBuilding");
|
||||
if (previewMutation.isError) buildStatus = t("previewBuildFailed");
|
||||
if (previewMutation.isSuccess) buildStatus = t("previewBuildReady");
|
||||
return (
|
||||
<Stack className={classes.preview} gap="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
@@ -63,8 +65,8 @@ export function PackagePreview({
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
loading={previewMutation.isPending}
|
||||
disabled={!existing || !parsed.success || !trusted || save.isPending}
|
||||
loading={previewMutation.isPending || save.isPending}
|
||||
disabled={!parsed.success || !trusted || busy}
|
||||
onClick={() => void runPreview()}
|
||||
>
|
||||
{t("preview")}
|
||||
@@ -99,9 +101,9 @@ export function PackagePreview({
|
||||
onChange={(event) => setLiveActions(event.currentTarget.checked)}
|
||||
label={t("previewLiveActions")}
|
||||
/>
|
||||
{!existing && (
|
||||
{!state.installationId && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("saveFirst")}
|
||||
{t("previewSavesFirst")}
|
||||
</Text>
|
||||
)}
|
||||
{stale && (
|
||||
@@ -109,32 +111,39 @@ export function PackagePreview({
|
||||
{t("previewStale")}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper withBorder className={classes.canvas} p="xs">
|
||||
{preview && (
|
||||
<PackagePreviewViewport width={width} height={height} scale={scale} colorScheme={previewTheme}>
|
||||
<TrustedWidgetPreview
|
||||
data={preview.displayData}
|
||||
width={width}
|
||||
height={height}
|
||||
displayScale={scale}
|
||||
surface={surface}
|
||||
onOptionsChange={setOptions}
|
||||
onSurfaceChange={setSurface}
|
||||
frozen={stale || previewMutation.isPending}
|
||||
onReady={previewReady}
|
||||
onLoadError={previewLoadError}
|
||||
/>
|
||||
</PackagePreviewViewport>
|
||||
<WidgetPreviewFrame
|
||||
dimensions={{ width, height, scale }}
|
||||
resize={{ size: state.gridSize, maximumSize: { width: 12, height: 12 }, onChange: state.setGridSize }}
|
||||
>
|
||||
{({ scale: fittedScale }) => (
|
||||
<>
|
||||
{preview && (
|
||||
<PackagePreviewViewport width={width} height={height} scale={fittedScale} colorScheme={previewTheme}>
|
||||
<TrustedWidgetPreview
|
||||
data={preview.displayData}
|
||||
width={width}
|
||||
height={height}
|
||||
displayScale={fittedScale}
|
||||
surface={surface}
|
||||
onOptionsChange={setOptions}
|
||||
onSurfaceChange={setSurface}
|
||||
frozen={stale || previewMutation.isPending}
|
||||
onReady={previewReady}
|
||||
onLoadError={previewLoadError}
|
||||
/>
|
||||
</PackagePreviewViewport>
|
||||
)}
|
||||
{!preview && (
|
||||
<Stack justify="center" align="center" mih={220} p="sm">
|
||||
<IconCode size={32} stroke={1} />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("previewEmpty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!preview && (
|
||||
<Stack justify="center" align="center" mih={220} p="sm">
|
||||
<IconCode size={32} stroke={1} />
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("previewEmpty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</WidgetPreviewFrame>
|
||||
<Group gap="xs" align="center">
|
||||
{preview && (
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -148,12 +157,18 @@ export function PackagePreview({
|
||||
)}
|
||||
</Group>
|
||||
<PackagePreviewControls state={state} />
|
||||
<Accordion variant="default">
|
||||
<Accordion variant="default" value={detailsOpen} onChange={setDetailsOpen}>
|
||||
<Accordion.Item value="trust">
|
||||
<Accordion.Control>{t("executionDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
<Badge variant="light">{buildStatus}</Badge>
|
||||
{previewMutation.error && <Alert color="red">{previewMutation.error.message}</Alert>}
|
||||
<Text size="sm">{t("browserExecution")}</Text>
|
||||
<Text size="sm">{parsed.success && parsed.data.manifest.entrypoints.server ? t("serverExecution") : t("browserOnlyExecution")}</Text>
|
||||
<Text size="sm">{t("trustedCode")}</Text>
|
||||
<Text size="sm">{t("runtimeLogs")}</Text>
|
||||
{detailsOpen && state.installationId && <PackageRuntimeStatus id={state.installationId} />}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("previewDescription")}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconLayoutDashboard, IconPlayerPause, IconPlayerPlay, IconPlugConnected } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { downloadPackage } from "./_package-document";
|
||||
|
||||
export function PackageRowActions({ entry }: { entry: RouterOutputs["customWidget"]["package"]["list"][number] }) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const utils = clientApi.useUtils();
|
||||
const fork = clientApi.customWidget.package.fork.useMutation();
|
||||
const enable = clientApi.customWidget.package.setEnabled.useMutation();
|
||||
const busy = fork.isPending || enable.isPending;
|
||||
const path = `/manage/custom-widgets/packages/${entry.id}`;
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
await action();
|
||||
await utils.customWidget.package.list.invalidate();
|
||||
} catch (error) {
|
||||
showErrorNotification({ title: t("title"), message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("actionsMenu")} loading={busy}>
|
||||
<IconDots size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item component={Link} href={`${path}?tab=installation`} leftSection={<IconLayoutDashboard size={16} />}>
|
||||
{t("addToBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href={`${path}?tab=connections`} leftSection={<IconPlugConnected size={16} />}>
|
||||
{t("connections")}
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href={`${path}?tab=workshop`} leftSection={<IconBuildingStore size={16} />}>
|
||||
{t("workshopTab")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item disabled={busy} leftSection={<IconCopy size={16} />} onClick={() => void run(async () => {
|
||||
await fork.mutateAsync({ id: entry.id, name: t("forkDefaultName", { name: entry.name }).slice(0, 128) });
|
||||
})}>
|
||||
{t("fork")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconDownload size={16} />} onClick={() => void run(async () => {
|
||||
const draft = await utils.customWidget.package.get.fetch({ id: entry.id });
|
||||
downloadPackage(entry.name, draft.source);
|
||||
})}>
|
||||
{t("exportDraft")}
|
||||
</Menu.Item>
|
||||
{entry.activeArtifactId && <Menu.Item leftSection={<IconDownload size={16} />} onClick={() => void run(async () => {
|
||||
downloadPackage(entry.name, await utils.customWidget.package.export.fetch({ id: entry.id }));
|
||||
})}>{t("exportActive")}</Menu.Item>}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
disabled={busy || !entry.activeArtifactId}
|
||||
leftSection={entry.enabled ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
|
||||
onClick={() => void run(() => enable.mutateAsync({ id: entry.id, enabled: !entry.enabled }))}
|
||||
>
|
||||
{entry.enabled ? t("disable") : t("enable")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Badge, Button, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
export function PackageRuntimeStatus({ id }: { id: string }) {
|
||||
const t = useI18n("customWidget.package.runtime");
|
||||
const status = clientApi.customWidget.package.runtimeStatus.useQuery({ id });
|
||||
const data = status.data;
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>{t("title")}</Text>
|
||||
<Text size="xs" c="dimmed">{t("description")}</Text>
|
||||
<Group>
|
||||
{data && <Badge variant="light">{t(data.state)}</Badge>}
|
||||
<Button variant="subtle" size="xs" loading={status.isFetching} onClick={() => void status.refetch()}>{t("refresh")}</Button>
|
||||
</Group>
|
||||
{data && "pid" in data && (
|
||||
<Text size="xs">{t("process", { pid: data.pid ?? "—", active: data.activeRequests, queued: data.queuedRequests })}</Text>
|
||||
)}
|
||||
{data && "error" in data && <Alert color="red">{data.error}</Alert>}
|
||||
{status.error && <Alert color="red">{status.error.message}</Alert>}
|
||||
<Button component={Link} href="/manage/tools/logs" target="_blank" rel="noopener noreferrer" variant="default" size="xs">{t("logs")}</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+51
-57
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button, Group, NavLink, Select, Stack, Tabs, Text, TextInput, Alert } from "@mantine/core";
|
||||
import { IconCode, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { Button, Group, Select, Stack, Tabs, Text, TextInput, Alert, Collapse } from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
|
||||
import { documentFromTemplate } from "./_package-document";
|
||||
import type { usePackageWorkspace } from "./_use-package-workspace";
|
||||
import classes from "./_package-workspace.module.css";
|
||||
import { PackageFileTree } from "./_package-file-tree";
|
||||
const PackageLanguageEditor = dynamic(() => import("./_package-language-editor"), { ssr: false });
|
||||
export function PackageSourceEditor({
|
||||
state,
|
||||
@@ -17,7 +18,8 @@ export function PackageSourceEditor({
|
||||
existing: boolean;
|
||||
}) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(true);
|
||||
const [manageFiles, setManageFiles] = useState(false);
|
||||
const { document, edit, selected, setSelected, path, setPath, busy, addFile, renameFile, removeFile, parsed } = state;
|
||||
return (
|
||||
<Tabs.Panel value="source" pt="md">
|
||||
@@ -25,6 +27,7 @@ export function PackageSourceEditor({
|
||||
{!existing && (
|
||||
<Select
|
||||
label={t("starter")}
|
||||
description={t("starterDescription")}
|
||||
placeholder={t("chooseStarter")}
|
||||
data={Object.entries(widgetReferencePackages).map(([key, template]) => ({
|
||||
value: key,
|
||||
@@ -36,7 +39,7 @@ export function PackageSourceEditor({
|
||||
const template = Object.entries(widgetReferencePackages).find(([key]) => key === starter)?.[1];
|
||||
if (!template) return;
|
||||
edit(documentFromTemplate(document.name, template));
|
||||
setSelected("/widget.json");
|
||||
setSelected(template.manifest.entrypoints.tile);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -63,59 +66,50 @@ export function PackageSourceEditor({
|
||||
<div className={classes.editor} data-package-document data-files-open={filesOpen}>
|
||||
{filesOpen && (
|
||||
<Stack gap="xs" className={classes.files}>
|
||||
<NavLink
|
||||
label={t("manifestFile")}
|
||||
active={selected === "/widget.json"}
|
||||
onClick={() => setSelected("/widget.json")}
|
||||
leftSection={<IconCode size={15} />}
|
||||
/>
|
||||
{Object.keys(document.files)
|
||||
.toSorted()
|
||||
.map((file) => (
|
||||
<NavLink
|
||||
key={file}
|
||||
label={file}
|
||||
active={selected === file}
|
||||
onClick={() => setSelected(file)}
|
||||
styles={{ label: { overflowWrap: "anywhere" } }}
|
||||
/>
|
||||
))}
|
||||
<TextInput
|
||||
aria-label={t("filePath")}
|
||||
placeholder={t("filePathExample")}
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.currentTarget.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Group gap={4}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
onClick={addFile}
|
||||
disabled={busy || !path.trim()}
|
||||
>
|
||||
{t("addFile")}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={renameFile}
|
||||
disabled={busy || !path.trim() || selected === "/widget.json"}
|
||||
>
|
||||
{t("renameFile")}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={12} />}
|
||||
onClick={removeFile}
|
||||
disabled={busy || selected === "/widget.json"}
|
||||
>
|
||||
{t("removeFile")}
|
||||
</Button>
|
||||
</Group>
|
||||
<PackageFileTree files={document.files} selected={selected} onSelect={setSelected} />
|
||||
<Button size="compact-xs" variant="subtle" onClick={() => setManageFiles((value) => !value)} aria-expanded={manageFiles}>
|
||||
{t("manageFiles")}
|
||||
</Button>
|
||||
<Collapse in={manageFiles}>
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
aria-label={t("filePath")}
|
||||
placeholder={t("filePathExample")}
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.currentTarget.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Group gap={4}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={12} />}
|
||||
onClick={addFile}
|
||||
disabled={busy || !path.trim()}
|
||||
>
|
||||
{t("addFile")}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={renameFile}
|
||||
disabled={busy || !path.trim() || selected === "/widget.json"}
|
||||
>
|
||||
{t("renameFile")}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={12} />}
|
||||
onClick={removeFile}
|
||||
disabled={busy || selected === "/widget.json"}
|
||||
>
|
||||
{t("removeFile")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
)}
|
||||
<PackageLanguageEditor state={state} />
|
||||
|
||||
+3
-1
@@ -29,11 +29,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.editor[data-files-open="true"] {
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
}
|
||||
.files {
|
||||
overflow: auto;
|
||||
max-height: 65vh;
|
||||
border-right: 1px solid var(--mantine-color-default-border);
|
||||
padding-right: var(--mantine-spacing-xs);
|
||||
}
|
||||
.preview {
|
||||
position: sticky;
|
||||
|
||||
+70
-12
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -11,10 +13,12 @@ import {
|
||||
TextInput,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Paper,
|
||||
} from "@mantine/core";
|
||||
import { IconArrowBackUp, IconArrowForwardUp, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
|
||||
import { PackagePreview } from "./_package-preview";
|
||||
import { PackageSplit } from "./_package-split";
|
||||
import { PackageAssistant } from "./_package-assistant";
|
||||
@@ -22,6 +26,8 @@ import { usePackageAssistant } from "./_use-package-assistant";
|
||||
import { PackageSourceEditor } from "./_package-source-editor";
|
||||
import { PackageConnections } from "./_package-connections";
|
||||
import { PackageOperations } from "./_package-operations";
|
||||
import { PackageWorkshop } from "./_package-workshop";
|
||||
import { PackageTransfer } from "./_package-transfer";
|
||||
import { usePackageWorkspace } from "./_use-package-workspace";
|
||||
import type { Installation } from "./_use-package-workspace";
|
||||
import classes from "./_package-workspace.module.css";
|
||||
@@ -41,6 +47,8 @@ export function PackageWorkspace({
|
||||
initialBoardId?: string;
|
||||
}) {
|
||||
const t = useI18n("customWidget.package");
|
||||
const searchParams = useSearchParams();
|
||||
const [tab, setTab] = useState(searchParams.get("tab") ?? "source");
|
||||
const workspace = usePackageWorkspace(
|
||||
installation,
|
||||
userId,
|
||||
@@ -73,6 +81,13 @@ export function PackageWorkspace({
|
||||
saveLocalBindings,
|
||||
} = workspace;
|
||||
usePackageAssistant(workspace);
|
||||
const current = workspace.installation;
|
||||
let status = t("draftOnly");
|
||||
if (current?.activeArtifactId) status = t("disabled");
|
||||
if (current?.activeArtifactId && current.enabled) status = t("active");
|
||||
let draftStatus = t("saved");
|
||||
if (editor.dirty) draftStatus = t("unsaved");
|
||||
if (!workspace.installationId) draftStatus = t("notSaved");
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -100,16 +115,30 @@ export function PackageWorkspace({
|
||||
<Button variant="subtle" component={Link} href="/manage/custom-widgets/packages">
|
||||
{t("library")}
|
||||
</Button>
|
||||
<Badge variant="light">{editor.dirty ? t("unsaved") : t("saved")}</Badge>
|
||||
<Badge variant="light">{status}</Badge>
|
||||
<Text size="xs" c="dimmed">{draftStatus}</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
loading={save.isPending}
|
||||
disabled={!document.name.trim() || previewMutation.isPending}
|
||||
disabled={!document.name.trim() || previewMutation.isPending || saveBindings.isPending}
|
||||
onClick={() => void saveDraft()}
|
||||
>
|
||||
{t("saveDraft")}
|
||||
</Button>
|
||||
<Button onClick={() => { setTab("installation"); setPane("edit"); }}>
|
||||
{t("installation")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
{!workspace.installationId && (
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600}>{t("gettingStarted")}</Text>
|
||||
<Text size="sm" c="dimmed">{t("gettingStartedDescription")}</Text>
|
||||
<Text size="sm">{t("manualSteps")}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
{error && (
|
||||
<Alert color="red" withCloseButton onClose={() => setError("")}>
|
||||
{error}
|
||||
@@ -120,6 +149,16 @@ export function PackageWorkspace({
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
{workspace.bindingsDirty && (
|
||||
<Alert color="yellow">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("connectionsUnsaved")}</Text>
|
||||
<Button size="xs" loading={saveBindings.isPending || save.isPending} onClick={saveLocalBindings}>
|
||||
{t("saveBindings")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
{editor.recovery && (
|
||||
<Alert color="yellow">
|
||||
<Stack gap="xs">
|
||||
@@ -176,16 +215,18 @@ export function PackageWorkspace({
|
||||
<PackageSplit
|
||||
userId={userId}
|
||||
pane={pane}
|
||||
preview={<PackagePreview state={workspace} existing={Boolean(installation)} />}
|
||||
preview={<PackagePreview state={workspace} />}
|
||||
>
|
||||
<Stack className={classes.authoring}>
|
||||
<Tabs defaultValue="source">
|
||||
<Tabs value={tab} onChange={(value) => { if (value) setTab(value); }} keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="source">{t("code")}</Tabs.Tab>
|
||||
<Tabs.Tab value="connections">{t("connections")}</Tabs.Tab>
|
||||
<Tabs.Tab value="options">{t("options")}</Tabs.Tab>
|
||||
<Tabs.Tab value="installation">{t("installation")}</Tabs.Tab>
|
||||
<Tabs.Tab value="workshop">{t("workshopTab")}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<PackageSourceEditor state={workspace} existing={Boolean(installation)} />
|
||||
<PackageSourceEditor state={workspace} existing={Boolean(workspace.installationId)} />
|
||||
<Tabs.Panel value="connections" pt="md">
|
||||
<PackageConnections
|
||||
requirements={parsed.success ? parsed.data.connections : {}}
|
||||
@@ -195,25 +236,42 @@ export function PackageWorkspace({
|
||||
saving={saveBindings.isPending}
|
||||
onConnectionSaved={workspace.connectionChanged}
|
||||
/>
|
||||
{!installation && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("saveFirst")}
|
||||
</Text>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="options" pt="md">
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">{t("optionsDescription")}</Text>
|
||||
{parsed.success && <PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />}
|
||||
{parsed.success && Object.keys(parsed.data.options).length === 0 && <Alert>{t("noOptions")}</Alert>}
|
||||
<Button variant="subtle" onClick={() => setOptions({})}>{t("resetOptions")}</Button>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="installation" pt="md">
|
||||
<PackageOperations
|
||||
installation={installation}
|
||||
installation={current}
|
||||
initialBoardId={initialBoardId}
|
||||
source={parsed.success ? parsed.data : undefined}
|
||||
options={effectiveOptions}
|
||||
setOptions={setOptions}
|
||||
dirty={editor.dirty}
|
||||
trusted={trusted}
|
||||
connectionsDirty={workspace.bindingsDirty}
|
||||
onTrustChange={workspace.setTrusted}
|
||||
onSave={() => void saveDraft()}
|
||||
saving={save.isPending}
|
||||
onError={setError}
|
||||
onMessage={setMessage}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="workshop" pt="md">
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">{t("workshopDescription")}</Text>
|
||||
{current ? (
|
||||
<>
|
||||
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty} />
|
||||
<PackageTransfer id={current.id} name={current.name} active={Boolean(current.activeArtifactId)} dirty={editor.dirty || workspace.bindingsDirty} onError={setError} />
|
||||
</>
|
||||
) : <Button onClick={() => void saveDraft()} loading={save.isPending}>{t("saveDraft")}</Button>}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</PackageSplit>
|
||||
|
||||
+7
-2
@@ -11,7 +11,12 @@ import type { PackageDocument } from "./_package-document";
|
||||
const maximumHistoryBytes = 2_000_000;
|
||||
const maximumRecoveryBytes = 1_000_000;
|
||||
|
||||
export function usePackageDocument(initial: PackageDocument, userId: string, installationId?: string) {
|
||||
export function usePackageDocument(
|
||||
initial: PackageDocument,
|
||||
userId: string,
|
||||
installationId?: string,
|
||||
connectionsDirty = false,
|
||||
) {
|
||||
const [document, setDocument] = useState(initial);
|
||||
const [saved, setSaved] = useState(initial);
|
||||
const [recovery, setRecovery] = useState<PackageDocument | null>(null);
|
||||
@@ -101,7 +106,7 @@ export function usePackageDocument(initial: PackageDocument, userId: string, ins
|
||||
return () => clearTimeout(timer);
|
||||
}, [definitionId, dirty, document, key, recovery]);
|
||||
|
||||
useUnsavedChangesGuard(dirty);
|
||||
useUnsavedChangesGuard(dirty || connectionsDirty, { navigationKey: installationId });
|
||||
|
||||
const discardRecovery = () => {
|
||||
setRecovery(null);
|
||||
|
||||
+49
-21
@@ -6,6 +6,7 @@ import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
|
||||
import { customWidgetPackageSchema, widgetPackagePathSchema } from "@homarr/custom-widgets/package";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import clock from "@homarr/widget-sdk/examples/clock.json";
|
||||
import { getLogicalTrackSize } from "~/components/board/layout/geometry";
|
||||
import { documentFromSource, documentFromTemplate, documentSource } from "./_package-document";
|
||||
import { usePackageDocument } from "./_use-package-document";
|
||||
export type Installation = RouterOutputs["customWidget"]["package"]["get"];
|
||||
@@ -24,7 +25,12 @@ export function usePackageWorkspace(
|
||||
if (installation) return documentFromSource(installation.name, installation.source);
|
||||
return documentFromTemplate(initialName ?? t("newName"), initialSource ?? clock);
|
||||
});
|
||||
const editor = usePackageDocument(initial, userId, installation?.id);
|
||||
const [installationId, setInstallationId] = useState(installation?.id);
|
||||
const [bindings, setBindings] = useState(installation?.bindings ?? initialBindings ?? {});
|
||||
const [savedBindings, setSavedBindings] = useState(bindings);
|
||||
const bindingsDirty =
|
||||
JSON.stringify(Object.entries(bindings).toSorted()) !== JSON.stringify(Object.entries(savedBindings).toSorted());
|
||||
const editor = usePackageDocument(initial, userId, installationId, bindingsDirty);
|
||||
const { document, edit } = editor;
|
||||
const [selected, setSelected] = useState(Object.keys(initial.files)[0] ?? "/widget.json");
|
||||
const [path, setPath] = useState("");
|
||||
@@ -37,16 +43,19 @@ export function usePackageWorkspace(
|
||||
const [liveActions, setLiveActions] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [bindings, setBindings] = useState(installation?.bindings ?? initialBindings ?? {});
|
||||
const savedInstallationId = useRef(installation?.id);
|
||||
const currentInstallation = clientApi.customWidget.package.get.useQuery(
|
||||
{ id: installationId ?? "" },
|
||||
{ enabled: Boolean(installationId), initialData: installation },
|
||||
);
|
||||
const [options, setOptions] = useState<Record<string, unknown>>({});
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [previewExecutionKey, setPreviewExecutionKey] = useState("");
|
||||
const [stale, setStale] = useState(false);
|
||||
const [surface, setSurface] = useState<"tile" | "advanced" | "configuration">("tile");
|
||||
const [width, setWidth] = useState(320);
|
||||
const [height, setHeight] = useState(260);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [gridSize, setGridSize] = useState({ width: 2, height: 2 });
|
||||
const [width, setWidth] = useState(getLogicalTrackSize(2));
|
||||
const [height, setHeight] = useState(getLogicalTrackSize(2));
|
||||
const [scale, setScale] = useState(0.9);
|
||||
const [previewTheme, setPreviewTheme] = useState<"system" | "light" | "dark">("system");
|
||||
const previewId = useRef<string | null>(null);
|
||||
const retiredPreviewIds = useRef(new Set<string>());
|
||||
@@ -93,11 +102,21 @@ export function usePackageWorkspace(
|
||||
setMessage("");
|
||||
const submitted = document;
|
||||
try {
|
||||
const result = await save.mutateAsync({ id: savedInstallationId.current, name: document.name, source });
|
||||
savedInstallationId.current = result.id;
|
||||
if (!installation && Object.keys(bindings).length > 0)
|
||||
await saveBindings.mutateAsync({ id: result.id, bindings });
|
||||
const result = await save.mutateAsync({ id: installationId, name: document.name, source });
|
||||
setInstallationId(result.id);
|
||||
editor.markSaved(submitted);
|
||||
// Stay in the editor: a full navigation can trigger the unsaved-changes guard
|
||||
// and discards selection, trust and the current preview after a successful save.
|
||||
if (!installationId) {
|
||||
const destination = new URL(window.location.href);
|
||||
destination.pathname = destination.pathname.replace(/\/packages\/.*$/u, `/packages/${result.id}`);
|
||||
if (initialBoardId) destination.searchParams.set("boardId", initialBoardId);
|
||||
window.history.replaceState(null, "", destination.pathname + destination.search);
|
||||
}
|
||||
if (!installationId && Object.keys(bindings).length > 0) {
|
||||
await saveBindings.mutateAsync({ id: result.id, bindings });
|
||||
setSavedBindings(bindings);
|
||||
}
|
||||
await Promise.all([
|
||||
utils.customWidget.package.get.invalidate(),
|
||||
utils.customWidget.package.list.invalidate(),
|
||||
@@ -105,24 +124,21 @@ export function usePackageWorkspace(
|
||||
utils.customWidget.package.inspectArtifact.invalidate(),
|
||||
]);
|
||||
setMessage(t("draftSaved"));
|
||||
if (!installation) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const destination = new URL(result.managementPath, window.location.origin);
|
||||
if (initialBoardId) destination.searchParams.set("boardId", initialBoardId);
|
||||
window.location.assign(destination.pathname + destination.search);
|
||||
}
|
||||
return result.id;
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
const runPreview = async () => {
|
||||
if (!installation || !trusted || !parsed.success) return;
|
||||
if (!trusted || !parsed.success || busy) return;
|
||||
const id = installationId ?? (await saveDraft());
|
||||
if (!id) return;
|
||||
resetExecution();
|
||||
setError("");
|
||||
const currentGeneration = generation.current;
|
||||
try {
|
||||
const next = await previewMutation.mutateAsync({
|
||||
id: installation.id,
|
||||
id,
|
||||
source,
|
||||
options: effectiveOptions,
|
||||
bindings,
|
||||
@@ -193,11 +209,15 @@ export function usePackageWorkspace(
|
||||
if (parsed.success && parsed.data.manifest.entrypoints.configuration)
|
||||
surfaceChoices.push({ value: "configuration", label: t("configuration") });
|
||||
const saveLocalBindings = () => {
|
||||
if (!installation) return;
|
||||
if (!installationId) {
|
||||
void saveDraft();
|
||||
return;
|
||||
}
|
||||
saveBindings.mutate(
|
||||
{ id: installation.id, bindings },
|
||||
{ id: installationId, bindings },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSavedBindings(bindings);
|
||||
setMessage(t("bindingsSaved"));
|
||||
void utils.customWidget.package.get.invalidate();
|
||||
void utils.customWidget.package.guestGrants.invalidate();
|
||||
@@ -222,7 +242,8 @@ export function usePackageWorkspace(
|
||||
};
|
||||
|
||||
return {
|
||||
installationId: savedInstallationId.current,
|
||||
installationId,
|
||||
installation: currentInstallation.data,
|
||||
editor,
|
||||
document,
|
||||
edit,
|
||||
@@ -243,6 +264,7 @@ export function usePackageWorkspace(
|
||||
message,
|
||||
setMessage,
|
||||
bindings,
|
||||
bindingsDirty,
|
||||
setBindings,
|
||||
options,
|
||||
setOptions,
|
||||
@@ -256,6 +278,12 @@ export function usePackageWorkspace(
|
||||
setWidth,
|
||||
height,
|
||||
setHeight,
|
||||
gridSize,
|
||||
setGridSize: (size: { width: number; height: number }) => {
|
||||
setGridSize(size);
|
||||
setWidth(getLogicalTrackSize(size.width));
|
||||
setHeight(getLogicalTrackSize(size.height));
|
||||
},
|
||||
scale,
|
||||
setScale,
|
||||
previewTheme,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
|
||||
|
||||
interface UnsavedChangesGuardOptions {
|
||||
guardBeforeUnload?: boolean;
|
||||
/** Reset the remembered URL when an unsaved resource gets its first saved identity. */
|
||||
navigationKey?: string;
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
|
||||
@@ -45,6 +47,6 @@ export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChanges
|
||||
confirmNavigation: (href) => confirmNavigationRef.current(href),
|
||||
guardBeforeUnload: options.guardBeforeUnload,
|
||||
}),
|
||||
[options.guardBeforeUnload],
|
||||
[options.guardBeforeUnload, options.navigationKey],
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,18 @@ import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/se
|
||||
|
||||
import { getArtifact, getInstallation, readPackageDraft } from "./records";
|
||||
import { widgetPackageAdminProcedure } from "./procedure";
|
||||
import { getWidgetPackageSupervisor } from "./invocations";
|
||||
|
||||
export const packageArtifactReviewProcedures = {
|
||||
runtimeStatus: widgetPackageAdminProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const installation = await getInstallation(ctx, input.id);
|
||||
if (!installation.activeArtifactId) return { state: "unactivated" as const };
|
||||
const { artifact } = await getArtifact(ctx, installation.activeArtifactId);
|
||||
if (!artifact.server) return { state: "browserOnly" as const };
|
||||
return getWidgetPackageSupervisor().inspect(artifact.digest);
|
||||
}),
|
||||
inspectArtifact: widgetPackageAdminProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -42,10 +42,13 @@ export async function getBoundConnection(ctx: PackageContext, bindings: Record<s
|
||||
const row = await ctx.db.query.customWidgetConnections.findFirst({ where: eq(customWidgetConnections.id, id) });
|
||||
if (!row) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Bound connection is unavailable" });
|
||||
const configuration = connectionConfigurationSchema.parse(JSON.parse(row.configuration));
|
||||
const integration = row.integrationId
|
||||
? await ctx.db.query.integrations.findFirst({ where: eq(integrations.id, row.integrationId) })
|
||||
: undefined;
|
||||
const secrets = z
|
||||
.record(z.string(), z.string())
|
||||
.parse(JSON.parse(decryptSecret(row.encryptedSecrets as `${string}.${string}`)));
|
||||
return { row, configuration, secrets };
|
||||
return { row, configuration: { ...configuration, integrationKind: integration?.kind }, secrets };
|
||||
}
|
||||
|
||||
export async function getBindingsDigest(ctx: PackageContext, bindings: Record<string, string>) {
|
||||
@@ -81,9 +84,14 @@ const admin = widgetPackageAdminProcedure;
|
||||
export const packageConnectionProcedures = {
|
||||
connections: admin.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.query.customWidgetConnections.findMany();
|
||||
const nativeIntegrations = await ctx.db.query.integrations.findMany({ columns: { id: true, kind: true } });
|
||||
const kinds = new Map(nativeIntegrations.map(({ id, kind }) => [id, kind]));
|
||||
return rows.map(({ encryptedSecrets: _secrets, ...row }) => ({
|
||||
...row,
|
||||
configuration: JSON.parse(row.configuration) as unknown,
|
||||
configuration: {
|
||||
...connectionConfigurationSchema.parse(JSON.parse(row.configuration)),
|
||||
integrationKind: row.integrationId ? kinds.get(row.integrationId) : undefined,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
saveConnection: admin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { widgetPackageAdminProcedure } from "./procedure";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nativeScaffoldProcedures } from "./native-scaffold";
|
||||
@@ -187,7 +187,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
|
||||
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
|
||||
for (const [name] of Object.entries(input.bindings)) {
|
||||
const connection = await getBoundConnection(ctx, input.bindings, name);
|
||||
const requirement = draft.success ? draft.data.connections[name] : undefined;
|
||||
const requirement = draft.success ? getWidgetConnectionRequirement(draft.data.connections, name) : undefined;
|
||||
if (requirement && !isWidgetConnectionCompatible(requirement, connection.configuration))
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
@@ -60,13 +60,17 @@ export async function prepareWidgetMigration(
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Placement ${placement.id}: ${issues[0]?.message}` });
|
||||
const mergedBindings = { ...bindings, ...placement.packageOptions.connectionBindings };
|
||||
for (const [name, requirement] of Object.entries(source.connections)) {
|
||||
if (requirement.optional && !mergedBindings[name]) continue;
|
||||
const connection = await getBoundConnection(ctx, mergedBindings, name);
|
||||
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
|
||||
});
|
||||
const names = getWidgetConnectionBindingNames(name, requirement, mergedBindings);
|
||||
if (!names.length && !requirement.optional)
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Connection '${name}' needs setup` });
|
||||
for (const bindingName of names) {
|
||||
const connection = await getBoundConnection(ctx, mergedBindings, bindingName);
|
||||
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const raw: unknown = parse(placement.options);
|
||||
if (!isRecord(raw)) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Placement options are invalid" });
|
||||
|
||||
@@ -59,29 +59,34 @@ export const nativeScaffoldProcedures = {
|
||||
for (const [name, requirement] of Object.entries(source.connections)) {
|
||||
if (requirement.kind !== "integration") continue;
|
||||
const accepted = acceptedIntegrationKinds(input.starter, name);
|
||||
const integration = bound.find(
|
||||
const matches = bound.filter(
|
||||
(candidate) => accepted.includes(candidate.kind) && !usedIntegrationIds.has(candidate.id),
|
||||
);
|
||||
if (!integration) continue;
|
||||
usedIntegrationIds.add(integration.id);
|
||||
let connection = await ctx.db.query.customWidgetConnections.findFirst({
|
||||
where: eq(customWidgetConnections.integrationId, integration.id),
|
||||
});
|
||||
if (!connection) {
|
||||
const id = createId();
|
||||
const row = {
|
||||
id,
|
||||
name: integration.name,
|
||||
integrationId: integration.id,
|
||||
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
|
||||
encryptedSecrets: encryptSecret("{}"),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await ctx.db.insert(customWidgetConnections).values(row);
|
||||
connection = row;
|
||||
let candidates = matches;
|
||||
if (!requirement.multiple) candidates = matches.slice(0, 1);
|
||||
for (const [index, integration] of candidates.entries()) {
|
||||
usedIntegrationIds.add(integration.id);
|
||||
let connection = await ctx.db.query.customWidgetConnections.findFirst({
|
||||
where: eq(customWidgetConnections.integrationId, integration.id),
|
||||
});
|
||||
if (!connection) {
|
||||
const id = createId();
|
||||
const row = {
|
||||
id,
|
||||
name: integration.name,
|
||||
integrationId: integration.id,
|
||||
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
|
||||
encryptedSecrets: encryptSecret("{}"),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await ctx.db.insert(customWidgetConnections).values(row);
|
||||
connection = row;
|
||||
}
|
||||
let bindingName = name;
|
||||
if (index > 0) bindingName = `${name}:${connection.id}`;
|
||||
bindings[bindingName] = connection.id;
|
||||
}
|
||||
bindings[name] = connection.id;
|
||||
}
|
||||
return { name: `${source.manifest.name} (custom)`, source, bindings, sourceBoardId: item.boardId };
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { customWidgetInstallations } from "@homarr/db/schema";
|
||||
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
|
||||
import { publishWidgetPackageChange } from "./events";
|
||||
import { getWidgetCollection } from "./portable-collection-records";
|
||||
@@ -34,7 +34,11 @@ export async function setWidgetCollectionBindings(
|
||||
message: `Widget '${entry.key}' has an invalid source draft. Repair it first.`,
|
||||
});
|
||||
const nextBindings = { ...entry.bindings };
|
||||
for (const name of Object.keys(entry.mapping)) delete nextBindings[name];
|
||||
for (const name of Object.keys(entry.mapping)) {
|
||||
for (const key of Object.keys(nextBindings)) {
|
||||
if (key === name || key.startsWith(`${name}:`)) delete nextBindings[key];
|
||||
}
|
||||
}
|
||||
Object.assign(nextBindings, mapCollectionBindings(entry.mapping, bindings));
|
||||
for (const name of Object.keys(entry.mapping)) {
|
||||
const requirement = entry.requirements[name];
|
||||
@@ -43,13 +47,14 @@ export async function setWidgetCollectionBindings(
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: `Widget '${entry.key}' no longer declares connection '${name}'. Review its package mapping.`,
|
||||
});
|
||||
if (!nextBindings[name]) continue;
|
||||
const connection = await getBoundConnection(ctx, nextBindings, name);
|
||||
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
|
||||
});
|
||||
for (const bindingName of getWidgetConnectionBindingNames(name, requirement, nextBindings)) {
|
||||
const connection = await getBoundConnection(ctx, nextBindings, bindingName);
|
||||
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
|
||||
});
|
||||
}
|
||||
}
|
||||
changes.push({
|
||||
id: entry.id,
|
||||
|
||||
@@ -78,7 +78,11 @@ export async function getWidgetCollection(ctx: PackageContext, importId: string)
|
||||
for (const entry of entries) {
|
||||
for (const [name, slot] of Object.entries(entry.mapping)) {
|
||||
const bindings = values.get(slot) ?? new Set<string>();
|
||||
bindings.add(entry.bindings[name] ?? "");
|
||||
const members = Object.entries(entry.bindings)
|
||||
.filter(([binding]) => binding === name || binding.startsWith(`${name}:`))
|
||||
.map(([binding, id]) => [binding.slice(name.length), id])
|
||||
.toSorted(([left], [right]) => (left ?? "").localeCompare(right ?? ""));
|
||||
bindings.add(JSON.stringify(members));
|
||||
values.set(slot, bindings);
|
||||
}
|
||||
}
|
||||
@@ -87,8 +91,8 @@ export async function getWidgetCollection(ctx: PackageContext, importId: string)
|
||||
for (const [slot, ids] of values) {
|
||||
if (ids.size > 1) bindingConflicts.push(slot);
|
||||
else {
|
||||
const id = [...ids][0];
|
||||
if (id) bindings[slot] = id;
|
||||
const members = JSON.parse([...ids][0] ?? "[]") as [string, string][];
|
||||
for (const [suffix, id] of members) bindings[`${slot}${suffix}`] = id;
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
WidgetCollectionContents,
|
||||
WidgetCollectionManifest,
|
||||
} from "@homarr/custom-widgets/package";
|
||||
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { getWidgetConnectionBindingNames, getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
|
||||
import { createWidgetCollectionArchive, validateWidgetCollectionArchive } from "@homarr/custom-widgets/package/server";
|
||||
|
||||
import { withWidgetArtifactReferenceLock } from "./artifact-references";
|
||||
@@ -37,7 +37,7 @@ export async function validateCollectionBindings(
|
||||
bindings: Record<string, string>,
|
||||
) {
|
||||
for (const [name] of Object.entries(bindings)) {
|
||||
const requirement = collection.connections[name];
|
||||
const requirement = getWidgetConnectionRequirement(collection.connections, name);
|
||||
if (!requirement) throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown collection connection '${name}'` });
|
||||
try {
|
||||
const connection = await getBoundConnection(ctx, bindings, name);
|
||||
@@ -55,9 +55,9 @@ export async function validateCollectionBindings(
|
||||
export function mapCollectionBindings(mapping: Record<string, string>, bindings: Record<string, string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(mapping).flatMap(([name, slot]) => {
|
||||
const id = bindings[slot];
|
||||
if (!id) return [];
|
||||
return [[name, id]];
|
||||
return Object.entries(bindings)
|
||||
.filter(([binding]) => binding === slot || binding.startsWith(`${slot}:`))
|
||||
.map(([binding, id]) => [`${name}${binding.slice(slot.length)}`, id]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,8 @@ export async function exportWidgetCollection(ctx: PackageContext, manifest: Widg
|
||||
const bindings = parseBindings(row.bindings);
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const [name, requirement] of Object.entries(source.connections)) {
|
||||
const identity = `${requirement.kind}:${requirement.serviceType ?? ""}:${bindings[name] ?? `${id}:${name}`}`;
|
||||
const members = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]).toSorted();
|
||||
const identity = JSON.stringify([requirement.kind, requirement.serviceType, requirement.integrationKind, requirement.multiple, members.length ? members : [id, name]]);
|
||||
let slot = shared.get(identity);
|
||||
if (!slot) {
|
||||
slot = `connection${shared.size + 1}`;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import { customWidgetConnections, groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { WidgetSdkInvocation } from "@homarr/custom-widgets/package/server";
|
||||
|
||||
import { readPackageHistory, widgetHistoryInputSchema } from "./history";
|
||||
@@ -56,13 +56,24 @@ export function createWidgetSdkBridge(
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (operation === "legacy.request") return invokeLegacyWidgetRequest(ctx, resolved, kind, input, signal);
|
||||
if (operation === "widget.context")
|
||||
if (operation === "widget.context") {
|
||||
const ids = Object.values(resolved.bindings);
|
||||
let labels = new Map<string, string>();
|
||||
if (ids.length) {
|
||||
const rows = await ctx.db.query.customWidgetConnections.findMany({
|
||||
where: inArray(customWidgetConnections.id, ids),
|
||||
columns: { id: true, name: true },
|
||||
});
|
||||
labels = new Map(rows.map((row) => [row.id, row.name]));
|
||||
}
|
||||
return {
|
||||
options: resolved.configuration,
|
||||
bindingNames: Object.keys(resolved.bindings),
|
||||
bindingLabels: Object.fromEntries(Object.entries(resolved.bindings).map(([name, id]) => [name, labels.get(id) ?? name])),
|
||||
installationId: resolved.installation.id,
|
||||
isPreview: Boolean(resolved.preview),
|
||||
};
|
||||
}
|
||||
if (operation === "integration.next" || operation === "integration.unsubscribe") {
|
||||
const { id } = z.object({ id: z.string() }).parse(input);
|
||||
const iterator = subscriptions.get(id);
|
||||
|
||||
@@ -90,6 +90,8 @@ export const widgetCollectionContentsSchema = z
|
||||
else if (!shared) issue(["connections", name], `collection slot '${slot}' is undeclared`);
|
||||
else if (!isWidgetConnectionCompatible(requirement, shared))
|
||||
issue(["connections", name], `slot '${slot}' requires compatible ${requirement.kind} settings`);
|
||||
else if (Boolean(requirement.multiple) !== Boolean(shared.multiple))
|
||||
issue(["connections", name], `slot '${slot}' must use the same repeatable connection setting`);
|
||||
else if (!requirement.optional && shared.optional)
|
||||
issue(["connections", name], `slot '${slot}' must be required because this widget requires it`);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,17 @@ export class WidgetPackageProcess {
|
||||
public get stopped() {
|
||||
return this.dead;
|
||||
}
|
||||
public inspect() {
|
||||
let state: "running" | "stopped" = "running";
|
||||
if (this.dead) state = "stopped";
|
||||
return {
|
||||
state,
|
||||
pid: this.child.pid ?? null,
|
||||
activeRequests: this.pending.size,
|
||||
queuedRequests: this.queued,
|
||||
lastUsed: this.lastUsed,
|
||||
};
|
||||
}
|
||||
public waitUntilReady() {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ export const widgetPackageConnectionSchema = z
|
||||
label: z.string().trim().min(1).max(128),
|
||||
kind: z.enum(["http", "integration", "service"]),
|
||||
serviceType: identifier.optional(),
|
||||
integrationKind: identifier.optional(),
|
||||
// Optional fields preserve the digest of existing package archives.
|
||||
multiple: z.boolean().optional(),
|
||||
optional: z.boolean().default(false),
|
||||
description: z.string().max(2048).optional(),
|
||||
})
|
||||
@@ -89,14 +92,41 @@ export const widgetPackageConnectionSchema = z
|
||||
|
||||
/** Shared by binding pickers, activation and portable collection imports. */
|
||||
export function isWidgetConnectionCompatible(
|
||||
requirement: { kind: string; serviceType?: string },
|
||||
configuration: { kind?: unknown; serviceType?: unknown },
|
||||
requirement: { kind: string; serviceType?: string; integrationKind?: string },
|
||||
configuration: { kind?: unknown; serviceType?: unknown; integrationKind?: unknown },
|
||||
) {
|
||||
if (requirement.kind !== configuration.kind) return false;
|
||||
if (requirement.kind === "service") return requirement.serviceType === configuration.serviceType;
|
||||
if (requirement.kind === "integration" && requirement.integrationKind)
|
||||
return requirement.integrationKind === configuration.integrationKind;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Repeatable connections retain the original named binding and add stable, namespaced members.
|
||||
* A colon cannot appear in a manifest connection name, so groups cannot overlap.
|
||||
*/
|
||||
export function getWidgetConnectionBindingNames(
|
||||
name: string,
|
||||
requirement: { multiple?: boolean },
|
||||
bindings: Record<string, string>,
|
||||
) {
|
||||
return Object.keys(bindings).filter(
|
||||
(key) => Boolean(bindings[key]) && (key === name || (requirement.multiple && key.startsWith(`${name}:`))),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWidgetConnectionRequirement<T extends { multiple?: boolean }>(
|
||||
requirements: Record<string, T>,
|
||||
name: string,
|
||||
) {
|
||||
const exact = requirements[name];
|
||||
if (exact) return exact;
|
||||
const prefix = name.split(":")[0];
|
||||
const group = prefix && requirements[prefix];
|
||||
if (group && group.multiple) return group;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const customWidgetPackageSchema = z
|
||||
.strictObject({
|
||||
$schema: z.literal(CUSTOM_WIDGET_PACKAGE_SCHEMA),
|
||||
|
||||
@@ -100,6 +100,17 @@ export class CustomWidgetPackageSupervisor {
|
||||
if (pending) (await pending.catch(() => undefined))?.stop();
|
||||
}
|
||||
|
||||
/** Read only: inspecting an inactive artifact must never start its server. */
|
||||
public async inspect(digest: string) {
|
||||
const pending = this.processes.get(digest);
|
||||
if (!pending) return { state: "idle" as const };
|
||||
try {
|
||||
return (await pending).inspect();
|
||||
} catch (error) {
|
||||
return { state: "failed" as const, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
public async cancelInstances(instanceIds: string[], options: { actions?: boolean } = {}): Promise<void> {
|
||||
for (const id of instanceIds) this.instanceGenerations.set(id, (this.instanceGenerations.get(id) ?? 0) + 1);
|
||||
const ids = new Set(instanceIds);
|
||||
|
||||
@@ -1196,7 +1196,7 @@
|
||||
"library": "Packages",
|
||||
"libraryDescription": "Build dashboard widgets with full TSX, CSS, modules, and optional server code. Import a package to inspect its source and connect your local services before activating it.",
|
||||
"search": "Search packages",
|
||||
"empty": "No matching packages. Create a source-free widget or import a package to get started.",
|
||||
"empty": "No widget packages yet.",
|
||||
"openWorkspace": "Open workspace",
|
||||
"draftOnly": "Draft only",
|
||||
"active": "Active",
|
||||
@@ -1204,11 +1204,12 @@
|
||||
"import": "Import package",
|
||||
"importTooLarge": "Package exceeds the 25 MB import limit.",
|
||||
"invalidPackage": "Choose a widget package or archive JSON file.",
|
||||
"name": "Installation name",
|
||||
"name": "Widget name",
|
||||
"unsaved": "Unsaved changes",
|
||||
"saved": "Saved draft",
|
||||
"notSaved": "Not saved yet",
|
||||
"saveDraft": "Save draft",
|
||||
"draftSaved": "Draft saved. Dashboard placements still use the active release.",
|
||||
"draftSaved": "Draft saved. Use on dashboard lets you activate this version and add it to a board. Existing tiles keep using their active version until then.",
|
||||
"recovery": "A recovery draft is available for this package. Local connection credentials are stored separately; recognized credential literals are removed from recovery source.",
|
||||
"restore": "Restore draft",
|
||||
"discard": "Discard recovery",
|
||||
@@ -1218,9 +1219,9 @@
|
||||
"historyDescription": "Undo/Redo covers the whole document, up to 100 changes and 2 MB. One browser recovery draft, up to 1 MB.",
|
||||
"code": "Source files",
|
||||
"connections": "Connections",
|
||||
"installation": "Activation & boards",
|
||||
"starter": "Reference starter",
|
||||
"chooseStarter": "Choose a reference package",
|
||||
"installation": "Use on dashboard",
|
||||
"starter": "Start from an example",
|
||||
"chooseStarter": "Choose a widget to customize",
|
||||
"clock": "World clock · no API required",
|
||||
"filePath": "Relative file path",
|
||||
"addFile": "Add",
|
||||
@@ -1231,13 +1232,13 @@
|
||||
"renameImports": "File renamed. Update imports and manifest entrypoints that refer to its previous path.",
|
||||
"draftInvalid": "This draft can be saved. Resolve these package errors before preview or activation.",
|
||||
"reviewTrust": "Review code & dependencies",
|
||||
"trustedCode": "This package runs trusted JavaScript in Homarr and may run Node code with Homarr's server privileges. Review every source file and dependency before running it. Preview also executes package code.",
|
||||
"trustedCode": "Trusted browser code has access to your Homarr session and browser APIs. Server code runs with Homarr's operating-system authority, including available credentials, networks and mounted files. The separate process is not a security sandbox. Only run code and dependencies you trust; preview executes them too.",
|
||||
"dependencies": "Additional dependencies",
|
||||
"hostOnly": "Shared Homarr modules only",
|
||||
"trustAcknowledgement": "I trust this source and its dependencies to run on my Homarr instance.",
|
||||
"preview": "Run preview",
|
||||
"exportDraft": "Export source draft",
|
||||
"saveFirst": "Save this draft first to enable preview, save local bindings, and activate it.",
|
||||
"saveFirst": "Save your draft, then activate it to make it available on dashboards.",
|
||||
"surface": "Preview surface",
|
||||
"tile": "Dashboard tile",
|
||||
"advanced": "Advanced view",
|
||||
@@ -1295,12 +1296,12 @@
|
||||
},
|
||||
"previewLiveSession": "Session: live SDK actions",
|
||||
"previewSimulatedSession": "Session: simulated SDK actions",
|
||||
"connectionsDescription": "Bind the package's named connections to services on this Homarr instance. Saving bindings updates every placement that inherits them; placements can override a connection in board settings.",
|
||||
"connectionsDescription": "Choose existing Homarr integrations or create local API connections. No trust acknowledgement is needed to configure them. Save connections to apply them to tiles that inherit this setup.",
|
||||
"noConnections": "This package needs no API or service connection.",
|
||||
"diagnose": "Diagnose",
|
||||
"newConnection": "Create local connection",
|
||||
"saveBindings": "Save installation bindings",
|
||||
"bindingsSaved": "Installation bindings saved.",
|
||||
"saveBindings": "Save connections",
|
||||
"bindingsSaved": "Connections saved for tiles that inherit this setup.",
|
||||
"connectionKind": "Connection type",
|
||||
"integration": "Homarr integration",
|
||||
"serverUrl": "Server URL",
|
||||
@@ -1316,13 +1317,13 @@
|
||||
"stringMap": "Enter a JSON object whose values are strings.",
|
||||
"previewOptions": "Preview options",
|
||||
"resetOptions": "Reset preview options to package defaults",
|
||||
"activation": "Activation",
|
||||
"activation": "Make this version available on dashboards",
|
||||
"activationDescription": "Activate the saved draft for all {count} dashboard placements of this installation. Existing placements keep their option values. A failed activation keeps the current release running.",
|
||||
"activeArtifact": "Active artifact",
|
||||
"none": "None",
|
||||
"saveBeforeActivate": "Save your changes before activating. Activation always uses the saved draft.",
|
||||
"activate": "Activate saved draft",
|
||||
"activated": "Saved package activated.",
|
||||
"activate": "Activate for dashboards",
|
||||
"activated": "This version is ready for dashboards. Add it to a board below, or open an existing placement.",
|
||||
"disable": "Disable installation",
|
||||
"enable": "Enable installation",
|
||||
"stateSaved": "Installation state saved.",
|
||||
@@ -1410,7 +1411,7 @@
|
||||
"connectionName": "Connection name",
|
||||
"assistant": {
|
||||
"open": "Ask Assistant",
|
||||
"context": "Uses this draft and public SDK context",
|
||||
"context": "Assistant can read this open draft and propose edits for you to review. You can undo an applied proposal in one step.",
|
||||
"request": "What would you like to build?",
|
||||
"requestPlaceholder": "Describe a widget, a design change, or a capability…",
|
||||
"send": "Propose changes",
|
||||
@@ -1431,7 +1432,14 @@
|
||||
"preparing": "Preparing proposed changes…",
|
||||
"invalidProposal": "This proposal is incomplete or invalid.",
|
||||
"reading": "Reading the open package draft…",
|
||||
"read": "Read the open package draft"
|
||||
"read": "Read the open package draft",
|
||||
"unavailable": "Assistant is not configured or is unavailable for your account.",
|
||||
"setup": "Open Assistant setup",
|
||||
"manual": "You can still choose an example, edit its files and preview it without Assistant.",
|
||||
"busy": "Assistant is already responding. Wait for it to finish before sending another request.",
|
||||
"sendFailed": "The request could not be sent. Check the Assistant panel and try again.",
|
||||
"create": "Build a widget with me",
|
||||
"createPrompt": "Help me create a widget package. Ask what app or idea I want to build, then propose an approachable first version using the available SDK. Explain any connection setup. Do not assume I want to keep the current example."
|
||||
},
|
||||
"sessionUnavailable": "Preview session unavailable",
|
||||
"sessionUnavailableDescription": "The session expired, was discarded, or belongs to another account. Sign in as the API key owner and create a fresh CLI preview.",
|
||||
@@ -1545,7 +1553,50 @@
|
||||
"previewThemeDark": "Dark",
|
||||
"previewScale": "Display scale",
|
||||
"savedSourceChanged": "The saved source changed. Reload and review it before activating.",
|
||||
"previewPane": "Preview"
|
||||
"previewPane": "Preview",
|
||||
"starterDescription": "Pick a working example, then edit its files or ask Assistant for changes. Switching examples replaces this draft; Undo restores it.",
|
||||
"gettingStarted": "Your widget, your way",
|
||||
"gettingStartedDescription": "Packages support full React, TypeScript, CSS and optional Node code. Custom JSX is the simpler template editor. Neither needs an API for a static widget.",
|
||||
"manualSteps": "Choose an example → connect services if needed → edit files and options → preview → use on dashboard. Assistant is optional.",
|
||||
"manageFiles": "Add, rename or remove files",
|
||||
"optionsDescription": "Adjust the preview without editing code. These values also start the setup for your next dashboard tile. Existing tiles keep their own settings.",
|
||||
"noOptions": "This example has no configurable options. Edit its files to customize it.",
|
||||
"useDescription": "Save a draft to keep your work. Activate it when you want boards to use that code, then add it to a board below. One package can appear on several boards.",
|
||||
"previewSavesFirst": "Your first preview also saves a draft. It does not add or change dashboard tiles.",
|
||||
"workshopTab": "Workshop",
|
||||
"workshopDescription": "Share your finished widget, publish a new release, or review upstream updates. Local connections and credentials stay on your Homarr instance.",
|
||||
"releaseTools": "Release details, disable and rollback",
|
||||
"chooseConnection": "Choose a connection or Homarr integration",
|
||||
"noCompatibleConnections": "No compatible connections yet. Create one below, then refresh this list.",
|
||||
"unavailableConnection": "Connection unavailable — choose a replacement",
|
||||
"createIntegration": "Create Homarr integration ↗",
|
||||
"connectionsUnsaved": "Connection changes are not saved yet. Save connections before using them on dashboards.",
|
||||
"customViewport": "Custom viewport and scale",
|
||||
"customViewportDescription": "Use the grid controls in the preview for normal board sizes. Pixel dimensions below override the grid for this preview only.",
|
||||
"browserExecution": "React and CSS run in your browser, inside Homarr, with its theme and UI context.",
|
||||
"serverExecution": "This package has a server entrypoint. Homarr starts a supervised Node process on demand when a handler is called and shares it between users of that artifact. Idle processes are released.",
|
||||
"browserOnlyExecution": "This package has no server entrypoint, so it needs no separate Node process. Static widgets do not need a connection.",
|
||||
"runtimeLogs": "Build and preview errors appear here. Server output is included in Homarr logs under custom-widget-package. The active release process can be inspected below.",
|
||||
"buildState": "Preview build: {state}",
|
||||
"actionsMenu": "Widget package actions",
|
||||
"forkDefaultName": "{name} fork",
|
||||
"previewBuilding": "Building preview…",
|
||||
"previewBuildReady": "Preview build ready",
|
||||
"previewBuildFailed": "Preview build failed",
|
||||
"previewNotBuilt": "Preview not built yet",
|
||||
"runtime": {
|
||||
"title": "Active release process",
|
||||
"description": "This is a snapshot from the Homarr replica serving this request. It does not start code or inspect a separate preview process. Refresh after opening a tile to see its current state.",
|
||||
"unactivated": "No active release",
|
||||
"browserOnly": "Browser only · no Node process",
|
||||
"idle": "No process on this replica",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"failed": "Startup failed",
|
||||
"refresh": "Refresh status",
|
||||
"process": "PID {pid} · {active} active requests · {queued} queued",
|
||||
"logs": "Open Homarr logs ↗"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.sonarr",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Sonarr operations",
|
||||
"description": "Two-server Sonarr operations: upcoming episodes, missing releases, queue progress, filtered advanced views and confirmed episode search through the native adapter.",
|
||||
"description": "Calendar, missing episodes and queue across any number of Sonarr integrations, with per-server search controls.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
@@ -21,11 +21,16 @@
|
||||
"permission": "modify",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["connection", "episodeIds"],
|
||||
"required": [
|
||||
"connection",
|
||||
"episodeIds"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"connection": {
|
||||
"enum": ["sonarr", "secondary"]
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"episodeIds": {
|
||||
"type": "array",
|
||||
@@ -42,21 +47,19 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"widget.tsx": "import { Alert, Badge, Button, Group, Loader, Progress, SegmentedControl, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport type { Connection, Overview, SonarrOptions } from \"./types\";\nexport default function Sonarr() {\n const host = useWidgetHost(); const services = useWidgetServices(); const options = useWidgetOptions<SonarrOptions>();\n const [tab,setTab] = useWidgetState(\"tab\",\"calendar\");\n const [source,setSource] = useWidgetState(\"source\",\"all\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const query = useWidgetQuery<Overview>(\"overview\",{}, {refetchInterval:60000});\n const search = useWidgetAction<{id:number;status:string},{connection:Connection;episodeIds:number[]}>(\"searchEpisodes\");\n const label = (connection:Connection) => connection === \"sonarr\" ? options.primaryName : options.secondaryName;\n const sources = query.data?.sources ?? [];\n const rows = sources.filter((entry) => source === \"all\" || source === entry.connection).flatMap((entry) => {\n let items = entry.calendar;\n if (tab === \"missing\") items = entry.missing.items;\n if (tab === \"queue\") items = entry.queue.items;\n return items.map((item,index) => ({...item,connection:entry.connection,rowKey:`${entry.connection}:${tab}:${item.id ?? index}`}));\n }).filter((item) => `${item.title} ${item.subTitle ?? item.seriesTitle ?? \"\"}`.toLowerCase().includes(filter.toLowerCase()));\n const visible = host.displayMode === \"advanced\" ? rows : rows.slice(0,4);\n const requestSearch = async (connection:Connection,id:number,title:string) => {\n if (await services.confirm({title:\"Search for this episode?\",message:`Ask ${label(connection)} to find and download an eligible release for ${title}?`})) search.mutate({connection,episodeIds:[id]});\n };\n return <Stack h=\"100%\" p=\"md\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge>{sources.reduce((sum,entry) => sum+entry.queue.totalCount,0)} queued</Badge></Group>\n <SegmentedControl value={tab} onChange={setTab} data={[{label:\"Upcoming\",value:\"calendar\"},{label:\"Missing\",value:\"missing\"},{label:\"Queue\",value:\"queue\"}]} />\n {sources.length > 1 && <Select aria-label=\"Sonarr server\" value={source} onChange={(value) => setSource(value ?? \"all\")} data={[{value:\"all\",label:\"All servers\"},...sources.map((entry) => ({value:entry.connection,label:label(entry.connection)}))]} />}\n {host.displayMode === \"advanced\" && <TextInput aria-label=\"Filter episodes\" placeholder=\"Filter series or episode…\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />}\n {query.isPending && <Loader size=\"sm\" />}\n {query.error && <Alert color=\"red\">{query.error.message}</Alert>}\n {sources.filter((entry) => entry.error).map((entry) => <Alert key={entry.connection} color=\"red\" title={label(entry.connection)}>{entry.error}</Alert>)}\n {search.error && <Alert color=\"red\">{search.error.message}</Alert>}\n {search.data?.id && <Text size=\"xs\" c=\"teal\">Search command {search.data.id}: {search.data.status}. Sonarr will select an eligible release.</Text>}\n {visible.map((item) => <Stack key={item.rowKey} gap={4}><Group justify=\"space-between\"><Text fw={500} size=\"sm\">{item.subTitle ?? item.seriesTitle ?? item.title}</Text><Badge size=\"xs\" variant=\"light\">{label(item.connection)}</Badge></Group><Text size=\"xs\" c=\"dimmed\">{item.title}{item.seasonNumber !== undefined && ` · S${item.seasonNumber} E${item.episodeNumber ?? \"?\"}`}</Text>{item.startDate && <Text size=\"xs\">{new Date(item.startDate).toLocaleString(host.locale)}</Text>}{item.percentComplete !== undefined && <Progress value={item.percentComplete} aria-label={`${item.title} download progress`} />}<Group gap=\"xs\">{(item.link || item.links?.[0]?.href) && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.navigate(item.link ?? item.links?.[0]?.href ?? \"\")}>Open in Sonarr</Button>}{tab === \"missing\" && typeof item.id === \"number\" && <Button size=\"compact-xs\" disabled={host.isEditMode || search.isPending} onClick={() => void requestSearch(item.connection,item.id!,item.title)}>Search episode</Button>}</Group></Stack>)}\n {query.data && !rows.length && <Text c=\"dimmed\">No episodes match this view.</Text>}\n <Group><Button size=\"xs\" variant=\"light\" onClick={() => void query.refetch()}>Refresh</Button>{host.displayMode === \"compact\" && <Button size=\"xs\" variant=\"subtle\" onClick={services.openAdvanced}>All episodes and filters</Button>}</Group>\n </Stack>;\n}\n",
|
||||
"server.ts": "import type { Connection, EpisodeRow, SearchInput, SonarrOptions } from \"./types\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nconst connections:Connection[] = [\"sonarr\", \"secondary\"];\nexport default defineWidgetServer({\n overview: async (_input, context) => {\n const {bindingNames,options} = await getWidgetServerContext<SonarrOptions>(context);\n const start = new Date();\n const end = new Date(start.getTime()+Math.max(1,Math.min(90,Number(options.days)||7))*86400000);\n const names = connections.filter((name) => bindingNames.includes(name));\n if (!names.length) throw new Error(\"Bind at least one Sonarr integration\");\n const sources = await Promise.all(names.map(async (connection) => {\n const call = <T,>(capability:string,input:unknown) => callWidgetIntegration<T>(context,{connection,capability,input});\n try {\n const [calendar,missing,queue] = await Promise.all([\n call<EpisodeRow[]>(\"sonarr.calendar\",{start:start.toISOString(),end:end.toISOString()}),\n call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.missing\",{limit:100}), call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.queue\",{limit:100}),\n ]);\n return {connection,calendar,missing,queue};\n } catch (error) { return {connection,calendar:[],missing:{items:[],totalCount:0},queue:{items:[],totalCount:0},error:error instanceof Error ? error.message : String(error)}; }\n }));\n return {sources};\n },\n searchEpisodes: (input:SearchInput,context) => {\n if (!connections.includes(input?.connection) || !Array.isArray(input?.episodeIds) || !input.episodeIds.length || input.episodeIds.length > 100 || input.episodeIds.some((id) => !Number.isInteger(id) || id < 1)) throw new Error(\"Choose valid episode IDs from a declared Sonarr server\");\n return callWidgetIntegration(context,{connection:input.connection,capability:\"sonarr.searchEpisodes\",input:{episodeIds:input.episodeIds}});\n },\n});\n",
|
||||
"types.ts": "export type Connection = \"sonarr\" | \"secondary\";\nexport interface EpisodeRow { id?:number; title:string; subTitle?:string; seriesTitle?:string; startDate?:string; seasonNumber?:number; episodeNumber?:number; percentComplete?:number; status?:string; timeLeft?:string|null; link?:string; links?:{href:string}[]; }\nexport interface Source { connection:Connection; calendar:EpisodeRow[]; missing:{items:EpisodeRow[];totalCount:number}; queue:{items:EpisodeRow[];totalCount:number}; error?:string; }\nexport interface Overview { sources:Source[]; }\nexport type SonarrOptions = {title:string;primaryName:string;secondaryName:string;days:number};\nexport type SearchInput = {connection:Connection;episodeIds:number[]};\n",
|
||||
"README.md": "# Sonarr operations\nBind the primary Sonarr integration and optionally a second one. Existing Homarr native query and interaction permissions apply. Each result retains its connection identity, including when different servers use the same numeric episode ID.\n\nCompact view shows four episodes; advanced view includes all loaded rows, source selection and text filtering. The reference reads up to 100 missing and queued episodes per server. A failure on one server does not hide the other server's result.\n\nSearch episode is a named modify action. It confirms the target server and episode before submitting the native Sonarr EpisodeSearch command. A queued command does not prove a release was found or downloaded. Default previews simulate actions. The integration sends the API key server-side; no key is embedded in portable source.\n\nAPI reference: https://github.com/Sonarr/Sonarr/blob/develop/src/NzbDrone.Core/IndexerSearch/EpisodeSearchCommand.cs and https://github.com/Sonarr/Sonarr/blob/develop/src/Sonarr.Api.V3/Commands/CommandController.cs\n"
|
||||
"widget.tsx": "import { Alert, Badge, Button, Group, Loader, Progress, SegmentedControl, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport type { Connection, Overview, SonarrOptions } from \"./types\";\nexport default function Sonarr() {\n const host = useWidgetHost(); const services = useWidgetServices(); const options = useWidgetOptions<SonarrOptions>();\n const [tab,setTab] = useWidgetState(\"tab\",\"calendar\");\n const [source,setSource] = useWidgetState(\"source\",\"all\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const query = useWidgetQuery<Overview>(\"overview\",{}, {refetchInterval:60000});\n const search = useWidgetAction<{id:number;status:string},{connection:Connection;episodeIds:number[]}>(\"searchEpisodes\");\n const label = (connection:Connection) => sources.find((source) => source.connection === connection)?.label ?? connection;\n const sources = query.data?.sources ?? [];\n const rows = sources.filter((entry) => source === \"all\" || source === entry.connection).flatMap((entry) => {\n let items = entry.calendar;\n if (tab === \"missing\") items = entry.missing.items;\n if (tab === \"queue\") items = entry.queue.items;\n return items.map((item,index) => ({...item,connection:entry.connection,rowKey:`${entry.connection}:${tab}:${item.id ?? index}`}));\n }).filter((item) => `${item.title} ${item.subTitle ?? item.seriesTitle ?? \"\"}`.toLowerCase().includes(filter.toLowerCase()));\n const visible = host.displayMode === \"advanced\" ? rows : rows.slice(0,4);\n const requestSearch = async (connection:Connection,id:number,title:string) => {\n if (await services.confirm({title:\"Search for this episode?\",message:`Ask ${label(connection)} to find and download an eligible release for ${title}?`})) search.mutate({connection,episodeIds:[id]});\n };\n return <Stack h=\"100%\" p=\"md\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge>{sources.reduce((sum,entry) => sum+entry.queue.totalCount,0)} queued</Badge></Group>\n <SegmentedControl value={tab} onChange={setTab} data={[{label:\"Upcoming\",value:\"calendar\"},{label:\"Missing\",value:\"missing\"},{label:\"Queue\",value:\"queue\"}]} />\n {sources.length > 1 && <Select aria-label=\"Sonarr server\" value={source} onChange={(value) => setSource(value ?? \"all\")} data={[{value:\"all\",label:\"All servers\"},...sources.map((entry) => ({value:entry.connection,label:label(entry.connection)}))]} />}\n {host.displayMode === \"advanced\" && <TextInput aria-label=\"Filter episodes\" placeholder=\"Filter series or episode…\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />}\n {query.isPending && <Loader size=\"sm\" />}\n {query.error && <Alert color=\"red\">{query.error.message}</Alert>}\n {sources.filter((entry) => entry.error).map((entry) => <Alert key={entry.connection} color=\"red\" title={label(entry.connection)}>{entry.error}</Alert>)}\n {search.error && <Alert color=\"red\">{search.error.message}</Alert>}\n {search.data?.id && <Text size=\"xs\" c=\"teal\">Search command {search.data.id}: {search.data.status}. Sonarr will select an eligible release.</Text>}\n {visible.map((item) => <Stack key={item.rowKey} gap={4}><Group justify=\"space-between\"><Text fw={500} size=\"sm\">{item.subTitle ?? item.seriesTitle ?? item.title}</Text><Badge size=\"xs\" variant=\"light\">{label(item.connection)}</Badge></Group><Text size=\"xs\" c=\"dimmed\">{item.title}{item.seasonNumber !== undefined && ` · S${item.seasonNumber} E${item.episodeNumber ?? \"?\"}`}</Text>{item.startDate && <Text size=\"xs\">{new Date(item.startDate).toLocaleString(host.locale)}</Text>}{item.percentComplete !== undefined && <Progress value={item.percentComplete} aria-label={`${item.title} download progress`} />}<Group gap=\"xs\">{(item.link || item.links?.[0]?.href) && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.navigate(item.link ?? item.links?.[0]?.href ?? \"\")}>Open in Sonarr</Button>}{tab === \"missing\" && typeof item.id === \"number\" && <Button size=\"compact-xs\" disabled={host.isEditMode || search.isPending} onClick={() => void requestSearch(item.connection,item.id!,item.title)}>Search episode</Button>}</Group></Stack>)}\n {query.data && !rows.length && <Text c=\"dimmed\">No episodes match this view.</Text>}\n <Group><Button size=\"xs\" variant=\"light\" onClick={() => void query.refetch()}>Refresh</Button>{host.displayMode === \"compact\" && <Button size=\"xs\" variant=\"subtle\" onClick={services.openAdvanced}>All episodes and filters</Button>}</Group>\n </Stack>;\n}\n",
|
||||
"server.ts": "import type { EpisodeRow, SearchInput, SonarrOptions } from \"./types\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetConnectionGroup, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nexport default defineWidgetServer({\n overview: async (_input, context) => {\n const {options} = await getWidgetServerContext<SonarrOptions>(context);\n const start = new Date();\n const end = new Date(start.getTime()+Math.max(1,Math.min(90,Number(options.days)||7))*86400000);\n const names = await getWidgetConnectionGroup(context, \"sonarr\");\n if (!names.length) throw new Error(\"Bind at least one Sonarr integration\");\n const sources = await Promise.all(names.map(async ({connection,label}) => {\n const call = <T,>(capability:string,input:unknown) => callWidgetIntegration<T>(context,{connection,capability,input});\n try {\n const [calendar,missing,queue] = await Promise.all([\n call<EpisodeRow[]>(\"sonarr.calendar\",{start:start.toISOString(),end:end.toISOString()}),\n call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.missing\",{limit:100}), call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.queue\",{limit:100}),\n ]);\n return {connection,label,calendar,missing,queue};\n } catch (error) { return {connection,label,calendar:[],missing:{items:[],totalCount:0},queue:{items:[],totalCount:0},error:error instanceof Error ? error.message : String(error)}; }\n }));\n return {sources};\n },\n searchEpisodes: async (input:SearchInput,context) => {\n const connections = await getWidgetConnectionGroup(context, \"sonarr\");\n if (!connections.some(({connection}) => connection === input?.connection) || !Array.isArray(input?.episodeIds) || !input.episodeIds.length || input.episodeIds.length > 100 || input.episodeIds.some((id) => !Number.isInteger(id) || id < 1)) throw new Error(\"Choose valid episode IDs from a declared Sonarr server\");\n return callWidgetIntegration(context,{connection:input.connection,capability:\"sonarr.searchEpisodes\",input:{episodeIds:input.episodeIds}});\n },\n});\n",
|
||||
"types.ts": "export type Connection = string;\nexport interface EpisodeRow { id?:number; title:string; subTitle?:string; seriesTitle?:string; startDate?:string; seasonNumber?:number; episodeNumber?:number; percentComplete?:number; status?:string; timeLeft?:string|null; link?:string; links?:{href:string}[]; }\nexport interface Source { connection:Connection; label:string; calendar:EpisodeRow[]; missing:{items:EpisodeRow[];totalCount:number}; queue:{items:EpisodeRow[];totalCount:number}; error?:string; }\nexport interface Overview { sources:Source[]; }\nexport type SonarrOptions = {title:string;days:number};\nexport type SearchInput = {connection:Connection;episodeIds:number[]};\n",
|
||||
"README.md": "# Sonarr operations\n\nChoose one or more Sonarr integrations in Connections. Each server contributes an independent calendar, missing list and queue. A failed server leaves other results visible. Search controls always name the selected server. Expand for the detailed view.\n\nThe server uses getWidgetConnectionGroup(context, \"sonarr\") to enumerate the repeatable connection requirement; adding a server requires no source edit.\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"sonarr": {
|
||||
"kind": "integration",
|
||||
"label": "Sonarr integration"
|
||||
},
|
||||
"secondary": {
|
||||
"kind": "integration",
|
||||
"label": "Second Sonarr integration",
|
||||
"optional": true
|
||||
"integrationKind": "sonarr",
|
||||
"multiple": true,
|
||||
"label": "Sonarr servers",
|
||||
"description": "Select one or more existing Homarr Sonarr integrations. Add another server at any time."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -65,16 +68,6 @@
|
||||
"label": "Title",
|
||||
"default": "Sonarr"
|
||||
},
|
||||
"primaryName": {
|
||||
"control": "text",
|
||||
"label": "Primary server label",
|
||||
"default": "Primary"
|
||||
},
|
||||
"secondaryName": {
|
||||
"control": "text",
|
||||
"label": "Second server label",
|
||||
"default": "Secondary"
|
||||
},
|
||||
"days": {
|
||||
"control": "number",
|
||||
"label": "Calendar days",
|
||||
|
||||
@@ -87,10 +87,19 @@ export function getWidgetServerContext<TOptions extends Record<string, unknown>
|
||||
options: TOptions;
|
||||
installationId: string;
|
||||
bindingNames: string[];
|
||||
bindingLabels: Record<string, string>;
|
||||
isPreview: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Enumerate every member of a repeatable connection requirement, including its original named binding. */
|
||||
export async function getWidgetConnectionGroup(context: WidgetServerContext, name: string) {
|
||||
const { bindingNames, bindingLabels } = await getWidgetServerContext(context);
|
||||
return bindingNames
|
||||
.filter((binding) => binding === name || binding.startsWith(`${name}:`))
|
||||
.map((connection) => ({ connection, label: bindingLabels[connection] ?? connection }));
|
||||
}
|
||||
|
||||
export function callWidgetIntegration<T = unknown>(context: WidgetServerContext, input: WidgetIntegrationInput) {
|
||||
return context.sdk.invoke("integration.call", input) as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts",
|
||||
"./preview": "./src/widget-preview.tsx",
|
||||
"./refetch-intervals": "./src/refetch-intervals.ts",
|
||||
"./widget-card-shell": "./src/widget-card-shell.tsx"
|
||||
},
|
||||
|
||||
@@ -7,26 +7,21 @@ import type { ComponentType, FormEvent, PropsWithChildren } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
CloseButton,
|
||||
ColorInput,
|
||||
Divider,
|
||||
Group,
|
||||
Input as MantineInput,
|
||||
NumberInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
@@ -56,6 +51,7 @@ import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell";
|
||||
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
|
||||
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
|
||||
import classes from "./widget-edit-modal.module.css";
|
||||
import { WidgetPreviewFrame } from "../widget-preview";
|
||||
|
||||
export interface WidgetEditModalState {
|
||||
options: Record<string, unknown>;
|
||||
@@ -152,23 +148,9 @@ const WidgetEditPreview = ({
|
||||
resize,
|
||||
PreviewWrapper,
|
||||
}: WidgetEditPreviewProps) => {
|
||||
const t = useI18n();
|
||||
const tItem = useI18n("item.edit");
|
||||
const board = useOptionalBoard();
|
||||
const generatedPreviewId = useId().replaceAll(":", "");
|
||||
const { ref, width: availableWidth, height: availableHeight } = useElementSize<HTMLDivElement>();
|
||||
const sourceWidth = Math.max(dimensions.width, 1);
|
||||
const sourceHeight = Math.max(dimensions.height, 1);
|
||||
let maximumScale = dimensions.scale ?? 0.9;
|
||||
if (!Number.isFinite(maximumScale) || maximumScale <= 0) maximumScale = 0.9;
|
||||
maximumScale = Math.min(maximumScale, 0.9);
|
||||
let previewScale = maximumScale;
|
||||
if (availableWidth > 0 && availableHeight > 0) {
|
||||
previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, maximumScale);
|
||||
}
|
||||
const previewWidth = sourceWidth * previewScale;
|
||||
const previewHeight = sourceHeight * previewScale;
|
||||
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false;
|
||||
const isMissingIntegration = integrationRequired && state.integrationIds.length === 0;
|
||||
@@ -188,129 +170,71 @@ const WidgetEditPreview = ({
|
||||
}
|
||||
const isPendingCustomWidget = kind === "customApi" && !itemId;
|
||||
const previewOpacity = (board?.opacity ?? 100) / 100;
|
||||
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || !resize) return;
|
||||
resize.onChange(
|
||||
normalizePreviewSize(
|
||||
{
|
||||
...resize.size,
|
||||
[dimension]: value,
|
||||
},
|
||||
resize.maximumSize,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className={classes.previewPanel} gap={0}>
|
||||
<Center ref={ref} className={classes.previewCanvas}>
|
||||
{resize && (
|
||||
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{tItem("preview.size")}
|
||||
</Text>
|
||||
<Tooltip label={t("item.moveResize.field.width.label")}>
|
||||
<NumberInput
|
||||
className={classes.previewSizeInput}
|
||||
aria-label={t("item.moveResize.field.width.label")}
|
||||
value={resize.size.width}
|
||||
onChange={(value) => handleResizeValue("width", value)}
|
||||
min={1}
|
||||
max={resize.maximumSize.width}
|
||||
step={1}
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
clampBehavior="strict"
|
||||
size="xs"
|
||||
leftSection={t("item.moveResize.field.width.shortLabel")}
|
||||
leftSectionPointerEvents="none"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="dimmed" aria-hidden>
|
||||
×
|
||||
</Text>
|
||||
<Tooltip label={t("item.moveResize.field.height.label")}>
|
||||
<NumberInput
|
||||
className={classes.previewSizeInput}
|
||||
aria-label={t("item.moveResize.field.height.label")}
|
||||
value={resize.size.height}
|
||||
onChange={(value) => handleResizeValue("height", value)}
|
||||
min={1}
|
||||
max={resize.maximumSize.height}
|
||||
step={1}
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
clampBehavior="strict"
|
||||
size="xs"
|
||||
leftSection={t("item.moveResize.field.height.shortLabel")}
|
||||
leftSectionPointerEvents="none"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
|
||||
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
|
||||
</Badge>
|
||||
{isMissingIntegration ? (
|
||||
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
|
||||
{tItem("preview.integrationRequired")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Box className={classes.previewViewport} w={previewWidth} h={previewHeight}>
|
||||
<WidgetCardShell
|
||||
className={classes.previewWidget}
|
||||
kind={kind}
|
||||
advancedOptions={state.advancedOptions}
|
||||
opacity={previewOpacity}
|
||||
radius={board?.itemRadius}
|
||||
w={sourceWidth}
|
||||
h={sourceHeight}
|
||||
p={0}
|
||||
data-grid-item-content
|
||||
style={{ transform: `scale(${previewScale})` }}
|
||||
>
|
||||
<WidgetTitleBadge
|
||||
<WidgetPreviewFrame dimensions={dimensions} resize={resize}>
|
||||
{({ width: sourceWidth, height: sourceHeight, scale: previewScale }) => (
|
||||
<>
|
||||
{isMissingIntegration ? (
|
||||
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
|
||||
{tItem("preview.integrationRequired")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Box className={classes.previewViewport} w={sourceWidth * previewScale} h={sourceHeight * previewScale}>
|
||||
<WidgetCardShell
|
||||
className={classes.previewWidget}
|
||||
kind={kind}
|
||||
advancedOptions={state.advancedOptions}
|
||||
opacity={previewOpacity}
|
||||
radius={board?.itemRadius}
|
||||
/>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
resetKeys={[state.options, state.integrationIds]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
|
||||
<SpotlightProvider>
|
||||
<IntegrationProvider integrations={previewIntegrations}>
|
||||
<div className={classes.previewContent} inert>
|
||||
<Component
|
||||
options={state.options as never}
|
||||
integrationIds={state.integrationIds}
|
||||
width={sourceWidth}
|
||||
height={sourceHeight}
|
||||
displayScale={previewScale}
|
||||
isEditMode={isPendingCustomWidget}
|
||||
displayMode="compact"
|
||||
boardId={boardId}
|
||||
itemId={componentItemId}
|
||||
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
|
||||
/>
|
||||
</div>
|
||||
</IntegrationProvider>
|
||||
</SpotlightProvider>
|
||||
</PreviewRuntimeBoundary>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
</WidgetCardShell>
|
||||
</Box>
|
||||
)}
|
||||
</Center>
|
||||
</Stack>
|
||||
w={sourceWidth}
|
||||
h={sourceHeight}
|
||||
p={0}
|
||||
data-grid-item-content
|
||||
style={{ transform: `scale(${previewScale})` }}
|
||||
>
|
||||
<WidgetTitleBadge
|
||||
advancedOptions={state.advancedOptions}
|
||||
opacity={previewOpacity}
|
||||
radius={board?.itemRadius}
|
||||
/>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
resetKeys={[state.options, state.integrationIds]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
|
||||
<SpotlightProvider>
|
||||
<IntegrationProvider integrations={previewIntegrations}>
|
||||
<div className={classes.previewContent} inert>
|
||||
<Component
|
||||
options={state.options as never}
|
||||
integrationIds={state.integrationIds}
|
||||
width={sourceWidth}
|
||||
height={sourceHeight}
|
||||
displayScale={previewScale}
|
||||
isEditMode={isPendingCustomWidget}
|
||||
displayMode="compact"
|
||||
boardId={boardId}
|
||||
itemId={componentItemId}
|
||||
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
|
||||
/>
|
||||
</div>
|
||||
</IntegrationProvider>
|
||||
</SpotlightProvider>
|
||||
</PreviewRuntimeBoundary>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
</WidgetCardShell>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</WidgetPreviewFrame>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Center, Group, NumberInput, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./modals/widget-edit-modal.module.css";
|
||||
|
||||
type Size = { width: number; height: number };
|
||||
|
||||
/** The same board grid controls and fitted canvas used by Edit Widget and package authoring. */
|
||||
export function WidgetPreviewFrame({ dimensions, resize, children }: {
|
||||
dimensions: Size & { scale?: number };
|
||||
resize?: { size: Size; maximumSize: Size; onChange(size: Size): void };
|
||||
children(dimensions: Size & { scale: number }): ReactNode;
|
||||
}) {
|
||||
const t = useI18n();
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const sourceWidth = Math.max(dimensions.width, 1);
|
||||
const sourceHeight = Math.max(dimensions.height, 1);
|
||||
let scale = dimensions.scale ?? 0.9;
|
||||
if (!Number.isFinite(scale) || scale <= 0) scale = 0.9;
|
||||
scale = Math.min(scale, 0.9);
|
||||
if (width > 0 && height > 0) scale = Math.min(width / sourceWidth, height / sourceHeight, scale);
|
||||
const change = (dimension: keyof Size, value: string | number) => {
|
||||
if (!resize || typeof value !== "number" || !Number.isFinite(value)) return;
|
||||
resize.onChange({ ...resize.size, [dimension]: Math.max(1, Math.min(Math.round(value), resize.maximumSize[dimension])) });
|
||||
};
|
||||
return (
|
||||
<Stack className={classes.previewPanel} gap={0}>
|
||||
<Center ref={ref} className={classes.previewCanvas}>
|
||||
{resize && (
|
||||
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={600} c="dimmed">{t("item.edit.preview.size")}</Text>
|
||||
{(["width", "height"] as const).map((dimension) => (
|
||||
<Group key={dimension} gap={6} wrap="nowrap">
|
||||
{dimension === "height" && <Text size="xs" c="dimmed" aria-hidden>×</Text>}
|
||||
<Tooltip label={t(`item.moveResize.field.${dimension}.label`)}>
|
||||
<NumberInput
|
||||
className={classes.previewSizeInput}
|
||||
aria-label={t(`item.moveResize.field.${dimension}.label`)}
|
||||
value={resize.size[dimension]}
|
||||
onChange={(value) => change(dimension, value)}
|
||||
min={1}
|
||||
max={resize.maximumSize[dimension]}
|
||||
step={1}
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
clampBehavior="strict"
|
||||
size="xs"
|
||||
leftSection={t(`item.moveResize.field.${dimension}.shortLabel`)}
|
||||
leftSectionPointerEvents="none"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
|
||||
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
|
||||
</Badge>
|
||||
{children({ width: sourceWidth, height: sourceHeight, scale })}
|
||||
</Center>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user