fix: simplify widget package publishing and navigation

This commit is contained in:
ajnart committed 2026-09-12 19:28:16 +02:00
1 parent 84058544a4
commit 9ff1019a3d
21 files changed
+811 -396

No files matched your search

@@ -333,14 +333,20 @@ In Workshop, select a release, inspect source files and dependencies, and option
**Import for review** opens the package workspace with a disabled local installation. Preview and activate it after
reviewing trust, then add it to a board.
The workspace's Workshop section checks for newer releases and reports local source changes. Staging an update
requires acknowledging replacement of the saved draft; the active release continues running until activation.
Fork or export local changes you want to keep before replacing the draft.
The **Packages** library shows each installation’s version, author, and **View on Workshop** link. Choose
**Check for updates**, then **Review update** to load a release into the editor. If your saved draft has local
changes, confirm its replacement first. The active release continues running until you activate the reviewed draft.
Use **Duplicate** or export local changes you want to keep before replacing them.
Publishing requires a Workshop account and an explicit review of the saved source for secrets, local information,
and permission to share the code and dependencies. Publish a new package, an immutable release of a package you own,
or an attributed fork. Give each release a new version in the manifest and describe its changes. Local bindings and
credentials remain on the Homarr installation.
Choose **Publish to Workshop** from the package’s action menu or its editor. Packages and Custom JSX widgets use the
same publishing form: sign in, add a title, description, and optional screenshots, then publish. The signed-in Workshop
account receives attribution. Homarr updates your existing listing or creates an attributed copy of another author’s
package automatically. **Release details** contains the version and optional release notes; updates suggest the next
patch version. Review source, dependencies, and fixtures before publishing. Local bindings and credentials remain
on the Homarr installation.
The management sidebar can collapse to icons using its toggle. It collapses automatically in the package editor to
leave more room for code and preview; expand it whenever you need the full labels. On mobile, use the header menu.
## Reference packages with local services
+5
View File
@@ -37,6 +37,11 @@ Custom Widget credentials are configured in your Homarr instance and are not par
Publish a saved Custom Widget from its action menu under **Management → Custom Widgets**. Custom CSS and manual
submissions can be published from the Workshop website.
Trusted v3 packages use the same publishing form, with attribution from your signed-in Workshop account.
Homarr selects a new listing, an update to your listing, or an attributed copy automatically. Version and optional
release notes are under **Release details**. Find version indicators, **Check for updates**, and **View on Workshop**
in the **Packages** library.
Trusted v3 packages publish immutable releases. When editing a release and its screenshots together, Homarr validates
the screenshot count before publishing, so an invalid screenshot edit does not consume the proposed package version.
@@ -3,8 +3,23 @@
import { extractErrorMessage, isRecord } from "@homarr/common";
import { useRef, useState } from "react";
import { Alert, Avatar, Badge, Button, Group, Stack, Text, ActionIcon, Menu } from "@mantine/core";
import {
Alert,
Avatar,
Badge,
Button,
Group,
Stack,
Text,
ActionIcon,
Menu,
Modal,
Checkbox,
Anchor,
} from "@mantine/core";
import {
IconRefresh,
IconExternalLink,
IconPackage,
IconPencil,
IconUpload,
@@ -25,60 +40,228 @@ import { Link } from "@homarr/ui";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { showErrorNotification } from "@homarr/notifications";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { downloadPackage } from "@homarr/custom-widgets/workbench/package";
export function PackageList({ initialData }: { initialData: RouterOutputs["customWidget"]["package"]["list"] }) {
const t = useI18n("customWidget.package");
const tCommon = useI18n("common");
const session = useWorkshopSession();
const installations = clientApi.customWidget.package.list.useQuery(undefined, { initialData });
const entries = installations.data ?? [];
return (
<Stack>
<Text c="dimmed">{t("libraryDescription")}</Text>
{entries.length === 0 && <NoResults icon={IconPackage} title={t("empty")} action={{ href: "/manage/custom-widgets/packages/new", label: t("create") }} />}
<Group justify="space-between" align="flex-start">
<Text c="dimmed" maw={700}>
{t("libraryDescription")}
</Text>
<WorkshopAccountButton session={session} />
</Group>
{entries.length === 0 && (
<NoResults
icon={IconPackage}
title={t("empty")}
action={{ href: "/manage/custom-widgets/packages/new", label: t("create") }}
/>
)}
<ManageCollectionList ariaLabel={t("title")}>
{entries.map((entry) => {
let status = t("draftOnly");
let color = "gray";
if (entry.activeArtifactId) {
status = t("disabled");
if (entry.enabled) {
status = t("active");
color = "green";
}
}
return (
<ManageCollectionItem
key={entry.id}
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">
{status}
</Badge>
}
description={
<Text size="sm" c="dimmed">
{t("placements", { count: entry.placementCount })}
</Text>
}
actions={
<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"
/>
);
})}
{entries.map((entry) => (
<PackageRow key={entry.id} entry={entry} workshopUserId={session.user?.id} />
))}
</ManageCollectionList>
</Stack>
);
}
type PackageEntry = RouterOutputs["customWidget"]["package"]["list"][number];
function PackageRow({ entry, workshopUserId }: { entry: PackageEntry; workshopUserId?: string }) {
const t = useI18n("customWidget.package");
const tCommon = useI18n("common");
const update = clientApi.customWidget.package.checkUpdate.useQuery(
{ id: entry.id },
{ enabled: false, retry: false },
);
const stage = clientApi.customWidget.package.stageWorkshopUpdate.useMutation();
const [opened, setOpened] = useState(false);
const [replace, setReplace] = useState(false);
let status = t("draftOnly");
let color = "gray";
if (entry.activeArtifactId) {
status = t("disabled");
if (entry.enabled) {
status = t("active");
color = "green";
}
}
const version = entry.activeVersion ?? entry.draftVersion;
const updateData = update.data;
const latest = updateData?.latest;
const ownsListing = Boolean(workshopUserId && entry.workshop?.author === workshopUserId);
return (
<>
<ManageCollectionItem
leading={
<Avatar size={40} radius="sm" color="pink">
<IconPackage size={20} stroke={1.5} />
</Avatar>
}
title={<Text fw={600}>{entry.name}</Text>}
badges={
<Group gap="xs">
<Badge color={color} variant="light">
{status}
</Badge>
{version && (
<Badge color="gray" variant="outline">
v{version}
</Badge>
)}
{entry.activeVersion && entry.draftVersion !== entry.activeVersion && (
<Badge variant="light" color="blue">
{t("draftVersion", { version: entry.draftVersion ?? "?" })}
</Badge>
)}
</Group>
}
description={
<Stack gap={4}>
{entry.description && (
<Text size="sm" c="dimmed" lineClamp={2}>
{entry.description}
</Text>
)}
<Group gap="xs">
<Text size="xs" c="dimmed">
{t("placements", { count: entry.placementCount })}
</Text>
{entry.author && (
<Text size="xs" c="dimmed">
{t("byAuthor", { name: entry.author })}
</Text>
)}
{ownsListing && (
<Text size="xs" c="dimmed">
{t("yourWorkshopListing")}
</Text>
)}
{entry.workshopUrl && (
<Anchor href={entry.workshopUrl} target="_blank" rel="noopener noreferrer" size="xs">
{t("openWorkshop")} <IconExternalLink size={12} />
</Anchor>
)}
</Group>
{entry.workshop && (
<Button
variant="subtle"
size="xs"
w="fit-content"
maw="100%"
leftSection={<IconRefresh size={14} />}
loading={update.isFetching}
onClick={() => {
if (updateData?.available) {
setOpened(true);
return;
}
void update.refetch().then((result) => {
if (result.data?.available) setOpened(true);
});
}}
>
{updateData?.available && latest
? t("updateAvailable", { version: latest.version })
: t("checkUpdates")}
</Button>
)}
{update.error && (
<Text size="xs" c="red">
{update.error.message}
</Text>
)}
{updateData && !updateData.available && !update.error && (
<Text size="xs" c="dimmed">
{t("upToDate")}
</Text>
)}
</Stack>
}
actions={
<Group gap="xs" wrap="wrap">
<Button
visibleFrom="sm"
component={Link}
href={`/manage/custom-widgets/packages/${entry.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{tCommon("action.edit")}
</Button>
<ActionIcon
hiddenFrom="sm"
component={Link}
href={`/manage/custom-widgets/packages/${entry.id}`}
variant="default"
aria-label={tCommon("action.edit")}
size="lg"
>
<IconPencil size={16} />
</ActionIcon>
<PackageRowActions entry={entry} />
</Group>
}
actionsAlignment="center"
/>
<Modal opened={opened} onClose={() => setOpened(false)} title={t("reviewUpdate")} centered>
<Stack>
<Text fw={600}>
{entry.name} · {entry.workshop?.version} → {latest?.version}
</Text>
<Text size="sm" c="dimmed">
{t("updateReviewDescription")}
</Text>
{latest?.changelog && (
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{latest.changelog}
</Text>
)}
{updateData?.hasLocalChanges && (
<Checkbox
checked={replace}
onChange={(event) => setReplace(event.currentTarget.checked)}
label={t("replaceModifiedDraft")}
/>
)}
{stage.error && <Alert color="red">{stage.error.message}</Alert>}
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)}>
{tCommon("action.cancel")}
</Button>
<Button
disabled={!latest || (updateData?.hasLocalChanges && !replace)}
loading={stage.isPending}
onClick={() => {
if (!latest || !updateData?.draftDigest) return;
stage.mutate(
{
id: entry.id,
releaseId: latest.id,
replaceLocalDraft: true,
expectedDraftDigest: updateData.draftDigest,
},
{ onSuccess: (result) => window.location.assign(result.managementPath) },
);
}}
>
{t("reviewUpdate")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
export function PackageImportButton() {
const t = useI18n("customWidget.package");
const input = useRef<HTMLInputElement>(null);
@@ -180,24 +363,48 @@ function PackageRowActions({ entry }: { entry: RouterOutputs["customWidget"]["pa
<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
component={Link}
href={`/manage/custom-widgets/publish/${entry.id}?kind=package`}
leftSection={<IconBuildingStore size={16} />}
>
{t("publishWorkshop")}
</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
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("duplicate")}
</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);
})}>
<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>}
{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}
@@ -72,7 +72,7 @@ export function PackageSourceEditor({
<Button size="compact-xs" variant="subtle" onClick={() => setManageFiles((value) => !value)} aria-expanded={manageFiles}>
{t("manageFiles")}
</Button>
<Collapse in={manageFiles}>
<Collapse expanded={manageFiles}>
<Stack gap="xs">
<TextInput
aria-label={t("filePath")}
@@ -3,168 +3,37 @@
import { extractErrorMessage } from "@homarr/common";
import { useState, useRef } from "react";
import {
Alert,
Button,
Checkbox,
Group,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
Accordion,
Badge,
SimpleGrid,
} from "@mantine/core";
import { Button, Checkbox, Group, Paper, Select, Stack, Text, Accordion, Badge, SimpleGrid } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { Link } from "@homarr/ui";
import { IconBuildingStore } from "@tabler/icons-react";
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
import { CodeEditor } from "~/components/custom-widgets/code-editor";
import type { CustomWidgetArtifact } from "@homarr/custom-widgets/package";
export function PackageWorkshop({ id, dirty }: { id: string; dirty: boolean }) {
const t = useI18n("customWidget.package");
const session = useWorkshopSession();
const update = clientApi.customWidget.package.checkUpdate.useQuery({ id });
const stage = clientApi.customWidget.package.stageWorkshopUpdate.useMutation();
const publish = clientApi.customWidget.package.publishWorkshop.useMutation();
const [replace, setReplace] = useState(false);
const [mode, setMode] = useState<"new" | "update" | "fork">("new");
const [changelog, setChangelog] = useState("");
const [forkedFrom, setForkedFrom] = useState("");
const [reviewed, setReviewed] = useState(false);
const updateData = update.data;
const linked = updateData?.origin !== undefined;
let submissionId = "";
if (linked) submissionId = updateData.origin.submissionId;
const submission = useWorkshopSubmissionQuery(session.client, submissionId);
return (
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("workshopLifecycle")}</Text>
{update.error && <Alert color="yellow">{update.error.message}</Alert>}
<Button variant="subtle" onClick={() => void update.refetch()}>
{t("checkUpdates")}
</Button>
{linked && (
<>
<Text size="sm">{t("upstreamRelease", { version: updateData.origin.version })}</Text>
{updateData.hasLocalChanges && <Alert color="yellow">{t("localChanges")}</Alert>}
{updateData.available && updateData.latest && (
<Stack gap="xs">
<Text fw={500}>{t("updateAvailable", { version: updateData.latest.version })}</Text>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{updateData.latest.changelog}
</Text>
<Checkbox
checked={replace}
onChange={(event) => setReplace(event.currentTarget.checked)}
label={t("replaceDraft")}
/>
<Button
disabled={!replace || dirty}
loading={stage.isPending}
onClick={() => {
if (!updateData.latest) return;
stage.mutate(
{
id,
releaseId: updateData.latest.id,
replaceLocalDraft: true,
expectedDraftDigest: updateData.draftDigest,
},
{ onSuccess: (result) => window.location.assign(result.managementPath) },
);
}}
>
{t("stageWorkshopUpdate")}
</Button>
</Stack>
)}
</>
<Paper withBorder p="md" radius="md">
<Stack align="flex-start">
<Text fw={600}>{t("publishWorkshop")}</Text>
<Text size="sm" c="dimmed">
{t("publishDescription")}
</Text>
{dirty && (
<Text size="sm" c="orange">
{t("saveBeforePublish")}
</Text>
)}
<Group justify="space-between">
<Text fw={600}>{t("publishWorkshop")}</Text>
<WorkshopAccountButton session={session} />
</Group>
<Select
label={t("publishMode")}
value={mode}
onChange={(value) => {
if (value === "new" || value === "update" || value === "fork") setMode(value);
}}
data={[
{ value: "new", label: t("publishNew") },
{ value: "update", label: t("publishUpdate") },
{ value: "fork", label: t("publishFork") },
]}
/>
{mode === "fork" && (
<TextInput
label={t("forkedFrom")}
value={forkedFrom}
onChange={(event) => setForkedFrom(event.currentTarget.value)}
/>
)}
<Textarea
label={t("changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
autosize
minRows={3}
/>
<Checkbox
checked={reviewed}
onChange={(event) => setReviewed(event.currentTarget.checked)}
label={t("publishAcknowledgement")}
/>
<Button
disabled={!session.user || dirty || !reviewed || (mode === "update" && !submission.data)}
loading={publish.isPending}
onClick={() => {
const token = session.client.authToken;
if (!token) return;
publish.mutate(
{
id,
token,
mode,
submissionId: submissionId || undefined,
expectedRevision: submission.data?.revision,
forkedFrom: forkedFrom || undefined,
changelog,
},
{
onSuccess: () => {
setReviewed(false);
void update.refetch();
},
},
);
}}
component={Link}
href={`/manage/custom-widgets/publish/${id}?kind=package`}
disabled={dirty}
leftSection={<IconBuildingStore size={16} />}
>
{t("publishWorkshop")}
</Button>
{publish.data && (
<Alert color="green">
<Text>{t("published", { version: publish.data.version })}</Text>
<Button
component="a"
href={publish.data.workshopUrl}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
>
{t("openWorkshop")}
</Button>
</Alert>
)}
{stage.error && <Alert color="red">{stage.error.message}</Alert>}
{publish.error && <Alert color="red">{publish.error.message}</Alert>}
</Stack>
</Paper>
);
@@ -172,13 +41,11 @@ export function PackageWorkshop({ id, dirty }: { id: string; dirty: boolean }) {
export function PackageTransfer({
id,
name,
active,
dirty,
onError,
}: {
id: string;
name: string;
active: boolean;
dirty: boolean;
onError(error: string): void;
@@ -186,8 +53,6 @@ export function PackageTransfer({
const t = useI18n("customWidget.package");
const input = useRef<HTMLInputElement>(null);
const [replace, setReplace] = useState(false);
const [forkName, setForkName] = useState(`${name} fork`);
const fork = clientApi.customWidget.package.fork.useMutation();
const update = clientApi.customWidget.package.stageUpdate.useMutation();
const changes = clientApi.customWidget.package.draftChanges.useQuery({ id });
const stage = async (file: File) => {
@@ -280,32 +145,6 @@ export function PackageTransfer({
</Stack>
</Paper>
)}
<Paper withBorder p="md">
<Stack>
<Text size="sm">{t("forkDescription")}</Text>
<TextInput
label={t("forkName")}
value={forkName}
onChange={(event) => setForkName(event.currentTarget.value)}
/>
<Button
variant="default"
disabled={dirty || !forkName.trim()}
loading={fork.isPending}
onClick={() =>
fork.mutate(
{ id, name: forkName },
{
onSuccess: (result) => window.location.assign(result.managementPath),
onError: (error) => onError(error.message),
},
)
}
>
{t("fork")}
</Button>
</Stack>
</Paper>
</Stack>
);
}
@@ -13,6 +13,7 @@ import {
Text,
TextInput,
Tooltip,
Title,
ActionIcon,
Paper,
} from "@mantine/core";
@@ -115,6 +116,7 @@ export function PackageWorkspace({
else editor.undo();
}}
>
<Title order={1} size="h2">{t("workbenchTitle")}</Title>
<Group justify="space-between" align="end" data-package-document>
<TextInput
flex={1}
@@ -292,7 +294,6 @@ export function PackageWorkspace({
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty || busy} />
<PackageTransfer
id={current.id}
name={current.name}
active={Boolean(current.activeArtifactId)}
dirty={editor.dirty || workspace.bindingsDirty || busy}
onError={setError}
@@ -1,9 +1,8 @@
import { redirect } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";
import { Container, Stack } from "@mantine/core";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { NativePackageWorkspace } from "../_package-convert";
import { PackageWorkspace } from "../_package-workspace";
@@ -15,7 +14,6 @@ export default async function NewWidgetPackagePage({
}) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect("/manage/custom-widgets");
const t = await getI18n("customWidget.package");
const query = await searchParams;
const starter = Object.entries(widgetReferencePackages).find(([key]) => key === query.starter)?.[1];
if (query.sourceItemId && query.starter && Object.hasOwn(widgetReferencePackages, query.starter))
@@ -31,7 +29,6 @@ export default async function NewWidgetPackagePage({
return (
<Container fluid>
<Stack>
<Title>{t("create")}</Title>
<PackageWorkspace userId={session.user.id} initialSource={starter} />
</Stack>
</Container>
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Checkbox,
@@ -25,12 +27,19 @@ import {
IconShieldCheck,
} from "@tabler/icons-react";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { extractErrorMessage } from "@homarr/common";
import { clientApi } from "@homarr/api/client";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { MAX_WORKSHOP_SCREENSHOT_BYTES, workshopScreenshotsSchema } from "@homarr/workshop/schema";
import {
MAX_WORKSHOP_SCREENSHOTS,
MAX_WORKSHOP_SCREENSHOT_BYTES,
workshopScreenshotsSchema,
} from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
@@ -43,24 +52,43 @@ import {
} from "~/components/workshop/workshop-publish-definition";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
export function WorkshopPublishForm({
widget,
kind = "definition",
}: {
widget: { id: string; name: string; description?: string; version?: string };
kind?: "definition" | "package";
}) {
const isPackage = kind === "package";
const listHref = isPackage ? "/manage/custom-widgets/packages" : "/manage/custom-widgets";
const t = useI18n("workshop");
const tCommon = useI18n("common");
const { formatBytes } = useByteFormatter();
const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const utils = clientApi.useUtils();
const [title, setTitle] = useState(widget.name.slice(0, 100));
const [description, setDescription] = useState(widget.description ?? "");
const [version, setVersion] = useState(widget.version ?? "1.0.0");
const [changelog, setChangelog] = useState("");
const [publishing, setPublishing] = useState(false);
const inFlight = useRef(false);
const [screenshotError, setScreenshotError] = useState<string | null>(null);
const [screenshots, setScreenshots] = useState<File[]>([]);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const createSubmission = useWorkshopCreateMutation(session.client);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id });
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: !isPackage });
const packageDraft = clientApi.customWidget.package.get.useQuery({ id: widget.id }, { enabled: isPackage });
const publishPackage = clientApi.customWidget.package.publishWorkshop.useMutation();
const packageSource = customWidgetPackageSchema.safeParse(packageDraft.data?.source);
const inspectedDefinition = isPackage ? packageDraft.data?.source : definition.data;
const loadError = isPackage ? packageDraft.isError : definition.isError;
const origin = packageDraft.data?.workshop;
const updating = Boolean(isPackage && session.user && origin?.author === session.user.id);
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
const definitionFingerprint = inspectedDefinition ? serializeWorkshopDefinition(inspectedDefinition) : null;
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const breadcrumb = <DynamicBreadcrumb dynamicMappings={new Map([[widget.id, widget.name]])} />;
@@ -68,17 +96,48 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
const publish = async () => {
// Publishing is immediate and public; a second in-flight call would create a
// duplicate listing.
if (createSubmission.isPending) return;
if (inFlight.current) return;
inFlight.current = true;
setPublishing(true);
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!inspectedDefinition) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
throw new Error(t("publish.invalidScreenshot", { maxSize: formatBytes(MAX_WORKSHOP_SCREENSHOT_BYTES) }));
}
if (isPackage) {
const token = session.client.authToken;
if (!token || !packageDraft.data || !packageSource.success) throw new Error(t("publish.error"));
if (updating && origin && screenshots.length > 0) {
const listing = await session.client.get(origin.submissionId);
if (listing.screenshots.length + screenshots.length > MAX_WORKSHOP_SCREENSHOTS)
throw new Error(t("publish.screenshotLimit"));
}
const result = await publishPackage.mutateAsync({
id: widget.id,
token,
mode: "auto",
title,
description,
version,
changelog,
expectedDraftDigest: packageDraft.data.draftDigest,
});
// A release is already public here. Screenshot failure must not invite a duplicate publication.
try {
await session.client.addScreenshots(result.submissionId, screenshots);
} catch (cause) {
setScreenshotError(extractErrorMessage(cause));
}
void utils.customWidget.package.list.invalidate();
void utils.customWidget.package.get.invalidate({ id: widget.id });
setPublishedSubmissionId(result.submissionId);
return;
}
let submissionId: string | null = null;
const result = await publishWorkshopDefinition({
inspectedDefinition: definition.data,
inspectedDefinition,
refetchDefinition: async () => (await definition.refetch()).data,
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
@@ -98,6 +157,9 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
setPublishedSubmissionId(submissionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
} finally {
inFlight.current = false;
setPublishing(false);
}
};
@@ -115,6 +177,11 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
<Text c="dimmed" size="sm" ta="center" maw={520}>
{t("publish.manageDescription")}
</Text>
{screenshotError && (
<Alert color="yellow">
{t("publish.screenshotUploadFailed")} {screenshotError}
</Alert>
)}
<Group gap="sm">
<Button component={Link} href={listHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
{t("publish.done")}
@@ -137,9 +204,10 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
const blocked =
!session.user ||
createSubmission.isPending ||
!definition.data ||
definition.isError ||
publishing ||
!inspectedDefinition ||
loadError ||
(isPackage && !packageSource.success) ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed);
@@ -165,13 +233,20 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
{isPackage ? t("publish.packageDescription") : t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!session.user && <Alert color="blue">{t("publish.signInHint")}</Alert>}
{session.user && (
<Alert color="blue">
<Text size="sm">{t("publish.publishingAs", { name: session.user.name })}</Text>
{updating && <Text size="sm">{t("publish.updatingListing")}</Text>}
{isPackage && origin && !updating && <Text size="sm">{t("publish.attributedCopy")}</Text>}
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Stack gap="md">
@@ -185,6 +260,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
</Box>
<TextInput
label={t("publish.titleField")}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
@@ -211,6 +287,38 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
</Stack>
</Paper>
{isPackage && (
<Accordion variant="contained">
<Accordion.Item value="release">
<Accordion.Control>
<Group gap="xs">
{t("publish.releaseDetails")}
<Badge variant="light">v{version}</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack>
<TextInput
label={t("publish.version")}
value={version}
onChange={(event) => setVersion(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.releaseNotes")}
description={t("publish.releaseNotesDescription")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
autosize
minRows={2}
maxLength={2000}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)}
{privateSourceNames.length > 0 && (
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
@@ -225,7 +333,15 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{isPackage && packageDraft.data && !packageSource.success && (
<Alert color="yellow">
<Text size="sm">{t("publish.packageInvalid")}</Text>
<Button component={Link} href={`/manage/custom-widgets/packages/${widget.id}`} variant="subtle" size="xs">
{tCommon("action.edit")}
</Button>
</Alert>
)}
{loadError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<ManageStickyFooter
@@ -238,13 +354,8 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
<Button component={Link} href={listHref} variant="default">
{tCommon("action.cancel")}
</Button>
<Button
size="md"
loading={createSubmission.isPending || definition.isFetching}
disabled={blocked}
onClick={() => void publish()}
>
{t("publish.action")}
<Button size="md" loading={publishing} disabled={blocked} onClick={() => void publish()}>
{updating ? t("publish.updateAction") : t("publish.action")}
</Button>
</ManageStickyFooter>
</Stack>
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -8,6 +10,7 @@ import { WorkshopPublishForm } from "./_workshop-publish-form";
interface WorkshopPublishPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ kind?: string }>;
}
export default async function WorkshopPublishPage(props: WorkshopPublishPageProps) {
@@ -15,6 +18,22 @@ export default async function WorkshopPublishPage(props: WorkshopPublishPageProp
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { id } = await props.params;
const { kind } = await props.searchParams;
if (kind === "package") {
const installation = await api.customWidget.package.get({ id }).catch(catchTrpcNotFound);
const parsed = customWidgetPackageSchema.safeParse(installation.source);
let version = parsed.data?.manifest.version ?? "1.0.0";
if (installation.workshop?.version === version) {
const [major, minor, patch] = version.split(/[.+-]/u);
version = `${major}.${minor}.${Number(patch) + 1}`;
}
return (
<WorkshopPublishForm
kind="package"
widget={{ id, name: installation.name, description: parsed.data?.manifest.description, version }}
/>
);
}
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
return <WorkshopPublishForm widget={{ id, name: definition.name }} />;
@@ -250,7 +250,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
<ClientShell hasNavigation>
<ClientShell hasNavigation management>
<MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain>
@@ -3,13 +3,24 @@
import { useCallback } from "react";
import { Burger } from "@mantine/core";
import { atom, useAtom } from "jotai";
import { useI18n } from "@homarr/translation/client";
export const navigationCollapsedAtom = atom(true);
export const ClientBurger = () => {
const t = useI18n("management");
const [collapsed, setCollapsed] = useAtom(navigationCollapsedAtom);
const toggle = useCallback(() => setCollapsed((collapsed) => !collapsed), [setCollapsed]);
return <Burger opened={!collapsed} onClick={toggle} hiddenFrom="sm" size="sm" />;
return (
<Burger
opened={!collapsed}
onClick={toggle}
hiddenFrom="sm"
size="sm"
aria-label={collapsed ? t("expandSidebar") : t("collapseSidebar")}
aria-expanded={!collapsed}
/>
);
};
@@ -3,11 +3,12 @@
import type { ReactElement, ReactNode } from "react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { NavLink } from "@mantine/core";
import { ActionIcon, Menu, NavLink, Tooltip } from "@mantine/core";
import { Link } from "@homarr/ui";
import { TourTarget } from "./header/tour-target";
import { useCompactNavigation } from "./shell";
export const CommonNavLink = (props: ClientNavigationLink) =>
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
@@ -40,24 +41,41 @@ const useClientPathname = () => {
const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname();
const compact = useCompactNavigation();
const tourId = props["data-onboarding-tour-id"];
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
if (compact) {
return withOptionalTourTarget(
tourId,
<Tooltip label={props.label} position="right">
<ActionIcon
component={Link}
href={props.href}
target={props.external ? "_blank" : undefined}
rel={props.external ? "noopener noreferrer" : undefined}
aria-label={props.label}
aria-current={isActive ? "page" : undefined}
variant={isActive ? "light" : "subtle"}
color={isActive ? undefined : "gray"}
size={44}
mb={4}
>
{props.icon}
</ActionIcon>
</Tooltip>,
);
}
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : (
<NavLink
component={Link}
label={props.label}
leftSection={props.icon}
href={props.href}
active={isActive}
/>
<NavLink component={Link} label={props.label} leftSection={props.icon} href={props.href} active={isActive} />
);
return withOptionalTourTarget(tourId, link);
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const { pathname, isClient } = useClientPathname();
const compact = useCompactNavigation();
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
const isActive = isClient && activeItemHref !== undefined;
const [opened, setOpened] = useState(false);
@@ -66,6 +84,43 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
if (isActive) setOpened(true);
}, [isActive]);
if (compact) {
return withOptionalTourTarget(
props["data-onboarding-tour-id"],
<Menu position="right-start" withinPortal>
<Menu.Target>
<Tooltip label={props.label} position="right">
<ActionIcon
aria-label={props.label}
variant={isActive ? "light" : "subtle"}
color={isActive ? undefined : "gray"}
size={44}
mb={4}
>
{props.icon}
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{props.label}</Menu.Label>
{props.items.map((item) => (
<Menu.Item
key={item.href}
component={Link}
href={item.href}
leftSection={item.icon}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
aria-current={activeItemHref === item.href ? "page" : undefined}
>
{item.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>,
);
}
const nav = (
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
{props.items.map((item) => (
@@ -1,10 +1,11 @@
import type { JSX } from "react";
import { AppShellNavbar, AppShellSection, Image, ScrollArea } from "@mantine/core";
import { AppShellSection, Image, ScrollArea } from "@mantine/core";
import type { TablerIcon, TablerIconProps } from "@homarr/ui";
import type { ClientNavigationLink } from "./navigation-link";
import { CommonNavLink } from "./navigation-link";
import { NavigationPanel } from "./shell";
interface MainNavigationProps {
headerSection?: JSX.Element;
@@ -14,7 +15,7 @@ interface MainNavigationProps {
export const MainNavigation = ({ headerSection, footerSection, links }: MainNavigationProps) => {
return (
<AppShellNavbar p="md">
<NavigationPanel>
{headerSection && <AppShellSection>{headerSection}</AppShellSection>}
<AppShellSection
grow
@@ -57,7 +58,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
})}
</AppShellSection>
{footerSection && <AppShellSection>{footerSection}</AppShellSection>}
</AppShellNavbar>
</NavigationPanel>
);
};
+80 -25
View File
@@ -1,9 +1,14 @@
"use client";
import type { PropsWithChildren } from "react";
import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai";
import { createContext, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { ActionIcon, AppShell, AppShellNavbar, Group, Text, Tooltip } from "@mantine/core";
import { useMediaQuery, useLocalStorage } from "@mantine/hooks";
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants";
@@ -12,40 +17,90 @@ import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
hasHeader?: boolean;
hasNavigation?: boolean;
management?: boolean;
}
const NavigationContext = createContext({ enabled: false, compact: false, toggle: () => undefined as void });
export const useCompactNavigation = () => useContext(NavigationContext).compact;
export function NavigationPanel({ children }: PropsWithChildren) {
const { enabled, compact, toggle } = useContext(NavigationContext);
const t = useI18n("management");
const label = compact ? t("expandSidebar") : t("collapseSidebar");
return (
<AppShellNavbar p={compact ? "xs" : "md"}>
{enabled && (
<Group justify={compact ? "center" : "space-between"} mb="sm" visibleFrom="sm">
{!compact && (
<Text size="xs" c="dimmed" fw={600}>
{t("metaTitle")}
</Text>
)}
<Tooltip label={label} position="right">
<ActionIcon variant="subtle" color="gray" onClick={toggle} aria-label={label}>
{compact ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
</ActionIcon>
</Tooltip>
</Group>
)}
{children}
</AppShellNavbar>
);
}
export const ClientShell = ({
hasHeader = true,
hasNavigation = true,
management = false,
children,
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
const [collapsed, setCollapsed] = useAtom(navigationCollapsedAtom);
const pathname = usePathname();
useEffect(() => {
if (management) setCollapsed(true);
}, [management, pathname, setCollapsed]);
const desktop = useMediaQuery("(min-width: 48em)");
const [preferredCompact, setPreferredCompact] = useLocalStorage({
key: "homarr.management-sidebar-compact",
defaultValue: false,
});
const [workbenchOverride, setWorkbenchOverride] = useState<{ path: string; compact: boolean } | null>(null);
const isWorkbench = /\/manage\/custom-widgets\/packages\/(?!collections(?:\/|$))[^/]+\/?$/u.test(pathname);
let compact = preferredCompact;
if (isWorkbench) compact = workbenchOverride?.path === pathname ? workbenchOverride.compact : true;
compact = management && Boolean(desktop) && compact;
const toggle = () => {
if (isWorkbench) setWorkbenchOverride({ path: pathname, compact: !compact });
else setPreferredCompact(!compact);
};
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
return (
<AppShell
{...backgroundProps}
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
// background image only covers as much height as the content needs, so collapsing a
// container short enough leaves flat page background showing below it instead of the
// background continuing to the bottom of the viewport.
mih={backgroundProps.bg ? "100dvh" : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
? {
width: 300,
breakpoint: "sm",
collapsed: { mobile: collapsed },
}
: undefined
}
padding="md"
>
{children}
</AppShell>
<NavigationContext.Provider value={{ enabled: management, compact, toggle }}>
<AppShell
{...backgroundProps}
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
// background image only covers as much height as the content needs, so collapsing a
// container short enough leaves flat page background showing below it instead of the
// background continuing to the bottom of the viewport.
mih={backgroundProps.bg ? "100dvh" : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
? {
width: compact ? 64 : 300,
breakpoint: "sm",
collapsed: { mobile: collapsed },
}
: undefined
}
padding="md"
>
{children}
</AppShell>
</NavigationContext.Provider>
);
};
@@ -8,8 +8,8 @@ import { packagePreviewProcedures } from "./preview-procedures";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import { customWidgetInstallations, items } from "@homarr/db/schema";
import { eq, inArray } from "@homarr/db";
import { customWidgetArtifacts, customWidgetInstallations, items } from "@homarr/db/schema";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { createTRPCRouter } from "../../../trpc";
@@ -30,6 +30,7 @@ import { getOrBuildWidgetArtifact, packageTransferProcedures } from "./transfer"
import { withWidgetInstallationLock } from "./coordination";
import { publishWidgetPackageChange } from "./events";
import { packageWorkshopProcedures } from "./workshop";
import { getWidgetWorkshopUrl, readWidgetWorkshopOrigin } from "./workshop-client";
import { setWidgetPackageEnabled } from "./enabled";
import { packageWebhookProcedures } from "./webhook-procedures";
import { packageArtifactReviewProcedures } from "./artifact-review";
@@ -67,7 +68,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
})
.query(async ({ ctx }) => {
const [rows, placements] = await Promise.all([
ctx.db.query.customWidgetInstallations.findMany({ columns: { draft: false, previousSnapshot: false } }),
ctx.db.query.customWidgetInstallations.findMany({ columns: { previousSnapshot: false } }),
ctx.db.query.items.findMany({ where: eq(items.kind, "customApi"), columns: { options: true } }),
]);
const counts = new Map<string, number>();
@@ -75,11 +76,30 @@ export const customWidgetPackageRouter = createTRPCRouter({
const options = parsePackagePlacement(placement.options);
if (options) counts.set(options.definitionId, (counts.get(options.definitionId) ?? 0) + 1);
}
return rows.map((row) => ({
...row,
bindings: parseBindings(row.bindings),
placementCount: counts.get(row.id) ?? 0,
}));
const artifactIds = rows.flatMap((row) => (row.activeArtifactId ? [row.activeArtifactId] : []));
const artifacts =
artifactIds.length > 0
? await ctx.db.query.customWidgetArtifacts.findMany({
where: inArray(customWidgetArtifacts.id, artifactIds),
columns: { id: true, version: true },
})
: [];
const versions = new Map(artifacts.map((artifact) => [artifact.id, artifact.version]));
return rows.map(({ draft, ...row }) => {
const source = customWidgetPackageSchema.safeParse(readPackageDraft(draft));
const origin = readWidgetWorkshopOrigin(row.origin);
return {
...row,
bindings: parseBindings(row.bindings),
placementCount: counts.get(row.id) ?? 0,
activeVersion: versions.get(row.activeArtifactId ?? "") ?? null,
draftVersion: source.data?.manifest.version ?? null,
description: source.data?.manifest.description ?? "",
author: origin?.authorName || source.data?.manifest.author || "",
workshop: origin,
workshopUrl: origin ? getWidgetWorkshopUrl(origin.submissionId) : null,
};
});
}),
get: admin
.meta({
@@ -95,6 +115,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
const { previousSnapshot: _snapshot, draft: _draft, ...installation } = row;
return {
...installation,
workshop: readWidgetWorkshopOrigin(row.origin),
source: readPackageDraft(row.draft),
draftDigest: packageDigest(row.draft),
bindings: parseBindings(row.bindings),
@@ -48,16 +48,19 @@ export function preserveWidgetCollectionOrigin(raw: string | null, nextOrigin: o
export function readPortableWidgetOrigin(raw: string | null): WidgetPortableOrigin | undefined {
const value = readOrigin(raw);
if (!value) return undefined;
const { collection: _collection, ...origin } = value;
const { collection: _collection, authorName: _authorName, ...origin } = value;
const direct = widgetPortableOriginSchema.safeParse(origin);
if (direct.success) return direct.data;
if (typeof value.forkedFrom !== "string") return undefined;
if (value.kind !== undefined || typeof value.forkedFrom !== "string") return undefined;
const upstream = readPortableWidgetOrigin(typeof value.origin === "string" ? value.origin : null);
let workshopUpstream;
if (upstream?.kind === "workshop") workshopUpstream = upstream;
if (upstream?.kind === "fork") workshopUpstream = upstream.upstream;
const parsed = widgetPortableOriginSchema.safeParse({
kind: "fork",
packageId: value.forkedFrom,
artifactDigest: value.artifact ?? undefined,
upstream: upstream?.kind === "workshop" ? upstream : undefined,
upstream: workshopUpstream,
});
if (parsed.success) return parsed.data;
return undefined;
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
import type { WorkshopPackageRelease } from "@homarr/workshop/package-releases";
import { parseWorkshopPackageRelease } from "@homarr/workshop/package-releases";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
@@ -24,9 +25,13 @@ export const widgetWorkshopOriginSchema = z.object({
sourceDigest: z.string(),
artifactDigest: z.string(),
author: z.string(),
authorName: z.string().optional(),
forkedFrom: z.string(),
});
export const getWidgetWorkshopUrl = (submissionId: string) =>
`${urls.workshopWebUrl}/${encodeURIComponent(submissionId)}`;
export function readWidgetWorkshopOrigin(raw: string | null) {
if (!raw) return null;
try {
@@ -41,27 +46,31 @@ export function readWidgetWorkshopOrigin(raw: string | null) {
export async function getWidgetWorkshopRelease(releaseId: string) {
return withWorkshopError(async () => {
const release = await widgetWorkshop.packages.get(releaseId, AbortSignal.timeout(15_000));
const parsed = parseWorkshopPackageRelease(release);
if (
packageDigest(parsed.source) !== release.sourceDigest ||
parsed.source.manifest.id !== release.packageId ||
parsed.source.manifest.version !== release.version
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
}
if (parsed.artifact) {
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
}
return {
...parsed,
release,
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
workshopUrl: `${urls.workshopWebUrl}/${encodeURIComponent(release.submission)}`,
};
return describeWidgetWorkshopRelease(release);
});
}
export function describeWidgetWorkshopRelease(release: WorkshopPackageRelease) {
const parsed = parseWorkshopPackageRelease(release);
if (
packageDigest(parsed.source) !== release.sourceDigest ||
parsed.source.manifest.id !== release.packageId ||
parsed.source.manifest.version !== release.version
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
}
if (parsed.artifact) {
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
}
return {
...parsed,
release,
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
workshopUrl: getWidgetWorkshopUrl(release.submission),
};
}
export function authenticatedWidgetWorkshop(token: string) {
const backend = new WorkshopBackend(urls.workshopApiUrl);
backend.pocketBase.authStore.save(token);
@@ -3,18 +3,19 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import { customWidgetPackageSchema, widgetPackageManifestSchema } from "@homarr/custom-widgets/package";
import type { CustomWidgetArtifact } from "@homarr/custom-widgets/package";
import { and, eq } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { preserveWidgetCollectionOrigin } from "./portable-collection-origin";
import { preserveWidgetCollectionOrigin, readPortableWidgetOrigin } from "./portable-collection-origin";
import { persistPackageArtifact } from "./preview-store";
import { withWidgetInstallationLock } from "./coordination";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
import { getArtifact, getInstallation, packageDigest } from "./records";
import { getArtifact, getInstallation, packageDigest, readPackageDraft } from "./records";
import {
authenticatedWidgetWorkshop,
describeWidgetWorkshopRelease,
getWidgetWorkshopRelease,
readWidgetWorkshopOrigin,
widgetWorkshop,
@@ -91,7 +92,7 @@ export const packageWorkshopProcedures = {
widgetWorkshop.packages.list(origin.submissionId, AbortSignal.timeout(15_000)),
);
const latest = releases[0];
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
const draft = customWidgetPackageSchema.safeParse(readPackageDraft(installation.draft));
let activeVersion: string | null = null;
if (installation.activeArtifactId)
activeVersion = (await getArtifact(ctx, installation.activeArtifactId)).source.manifest.version;
@@ -153,7 +154,11 @@ export const packageWorkshopProcedures = {
.input(
idInput.extend({
token: z.string().min(1).max(20_000),
mode: z.enum(["new", "update", "fork"]),
mode: z.enum(["auto", "new", "update", "fork"]).default("auto"),
title: z.string().trim().min(3).max(100).optional(),
description: z.string().max(2048).optional(),
version: widgetPackageManifestSchema.shape.version.optional(),
expectedDraftDigest: z.string().min(1).optional(),
submissionId: z.string().optional(),
expectedRevision: z.number().int().positive().optional(),
forkedFrom: z.string().optional(),
@@ -161,56 +166,89 @@ export const packageWorkshopProcedures = {
}),
)
.mutation(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
const source = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
const origin = readWidgetWorkshopOrigin(installation.origin);
let submissionId: string | undefined;
let forkedFrom: string | undefined;
if (input.mode === "update") {
submissionId = input.submissionId ?? origin?.submissionId;
if (!submissionId || !input.expectedRevision)
throw new TRPCError({
code: "BAD_REQUEST",
message: "An update requires its Workshop submission and current revision",
});
} else if (input.mode === "fork") {
forkedFrom = input.forkedFrom ?? origin?.releaseId;
if (!forkedFrom)
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id)
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
}
let artifact: CustomWidgetArtifact | undefined;
if (installation.activeArtifactId) {
const active = await getArtifact(ctx, installation.activeArtifactId);
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
}
const release = await withWorkshopError(() =>
authenticatedWidgetWorkshop(input.token).packages.publish({
source,
artifact,
submissionId,
expectedRevision: input.expectedRevision,
forkedFrom,
changelog: input.changelog,
title: installation.name,
description: source.manifest.description,
}),
);
const published = await getWidgetWorkshopRelease(release.id);
await ctx.db
.update(customWidgetInstallations)
.set({
origin: JSON.stringify(preserveWidgetCollectionOrigin(installation.origin, published.origin)),
updatedAt: new Date(),
})
.where(eq(customWidgetInstallations.id, input.id));
return {
releaseId: release.id,
submissionId: release.submission,
version: release.version,
workshopUrl: published.workshopUrl,
};
return withWidgetInstallationLock(input.id, async () => {
const installation = await getInstallation(ctx, input.id);
if (input.expectedDraftDigest && packageDigest(installation.draft) !== input.expectedDraftDigest)
throw new TRPCError({ code: "CONFLICT", message: "The saved draft changed. Reload it before publishing." });
const source = customWidgetPackageSchema.parse(readPackageDraft(installation.draft));
const origin = readWidgetWorkshopOrigin(installation.origin);
const backend = authenticatedWidgetWorkshop(input.token);
const user = await withWorkshopError(() => backend.refreshAuth());
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "Sign in to Workshop before publishing." });
let submissionId: string | undefined;
let forkedFrom: string | undefined;
let expectedRevision = input.expectedRevision;
if (input.mode === "auto") {
if (!input.expectedDraftDigest)
throw new TRPCError({ code: "BAD_REQUEST", message: "Review the saved draft before publishing." });
const portable = readPortableWidgetOrigin(installation.origin);
if (origin?.author === user.id) {
const listing = await withWorkshopError(() => backend.get(origin.submissionId));
submissionId = listing.id;
expectedRevision = listing.revision;
} else if (origin) {
forkedFrom = origin.releaseId;
} else if (portable?.kind === "fork" && portable.upstream) {
const upstream = readWidgetWorkshopOrigin(JSON.stringify(portable.upstream));
forkedFrom = upstream?.releaseId;
}
if (forkedFrom) {
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id) source.manifest.id = `widget-${createId()}`;
}
source.manifest.author = user.name;
source.manifest.name = input.title ?? installation.name;
source.manifest.description = input.description ?? source.manifest.description;
source.manifest.version = input.version ?? source.manifest.version;
} else if (input.mode === "update") {
submissionId = input.submissionId ?? origin?.submissionId;
if (!submissionId || !expectedRevision)
throw new TRPCError({
code: "BAD_REQUEST",
message: "An update requires its Workshop submission and current revision",
});
} else if (input.mode === "fork") {
forkedFrom = input.forkedFrom ?? origin?.releaseId;
if (!forkedFrom)
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
const original = await getWidgetWorkshopRelease(forkedFrom);
if (original.source.manifest.id === source.manifest.id)
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
}
let artifact: CustomWidgetArtifact | undefined;
if (installation.activeArtifactId) {
const active = await getArtifact(ctx, installation.activeArtifactId);
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
}
const release = await withWorkshopError(() =>
backend.packages.publish({
source,
artifact,
submissionId,
expectedRevision,
forkedFrom,
changelog: input.changelog,
title: input.title ?? installation.name,
description: input.description ?? source.manifest.description,
}),
);
const published = describeWidgetWorkshopRelease(release);
await ctx.db
.update(customWidgetInstallations)
.set({
origin: JSON.stringify(
preserveWidgetCollectionOrigin(installation.origin, { ...published.origin, authorName: user.name }),
),
draft: JSON.stringify(source),
updatedAt: new Date(),
})
.where(eq(customWidgetInstallations.id, input.id));
return {
releaseId: release.id,
submissionId: release.submission,
version: release.version,
workshopUrl: published.workshopUrl,
};
});
}),
};
@@ -37,7 +37,7 @@ export const widgetPortableWorkshopOriginSchema = z.strictObject({
releaseId: z.string().max(128),
version: z.string().max(128),
sourceDigest: digest,
artifactDigest: digest,
artifactDigest: digest.or(z.literal("")),
author: z.string().max(256),
forkedFrom: z.string().max(128),
});
+28 -3
View File
@@ -1191,6 +1191,17 @@
"savedPlaceholder": "••••••• (saved, leave empty to keep)"
},
"package": {
"workbenchTitle": "Widget editor",
"publishDescription": "Add a title, description, and screenshots using the Workshop publishing form. Your Workshop account supplies the attribution.",
"saveBeforePublish": "Save your draft before publishing.",
"draftVersion": "Draft v{version}",
"byAuthor": "By {name}",
"yourWorkshopListing": "Your Workshop listing",
"upToDate": "No newer Workshop release",
"reviewUpdate": "Review update",
"updateReviewDescription": "Load this release into the editor for review. Your active widgets keep running until you activate the update. Connections and placement settings are preserved.",
"replaceModifiedDraft": "Replace my modified saved draft with this release",
"duplicate": "Duplicate",
"title": "Widget packages",
"create": "Create package",
"library": "Packages",
@@ -1379,7 +1390,7 @@
"changelog": "Release notes",
"publishAcknowledgement": "I reviewed the complete saved source for secrets and local information, and I have permission to share this code and its dependencies publicly.",
"published": "Published release {version}.",
"openWorkshop": "Open Workshop page",
"openWorkshop": "View on Workshop",
"manifestFile": "widget.json",
"filePathExample": "components/Status.tsx",
"serverUrlExample": "http://sonarr:8989",
@@ -1564,7 +1575,7 @@
"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.",
"workshopDescription": "Share your saved widget using the Workshop publishing form. Check for updates and view installed versions in the Packages library.",
"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.",
@@ -1579,7 +1590,7 @@
"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",
"forkDefaultName": "{name} copy",
"previewBuilding": "Building preview…",
"previewBuildReady": "Preview build ready",
"previewBuildFailed": "Preview build failed",
@@ -1675,6 +1686,18 @@
"publish": {
"title": "Publish to Workshop",
"introTitle": "Share this widget with the community",
"packageDescription": "Share the saved package source. Connections, credentials, and stored widget data stay in your Homarr instance. Review code and fixtures before publishing.",
"publishingAs": "Publishing as {name}",
"updatingListing": "A new release will update your existing Workshop listing.",
"attributedCopy": "Your copy will have its own listing and retain a link to the original author’s release.",
"releaseDetails": "Release details",
"version": "Version",
"releaseNotes": "Release notes (optional)",
"releaseNotesDescription": "Tell existing users what changed in this release.",
"updateAction": "Publish update",
"packageInvalid": "This saved package needs repair before it can be published. Open the editor to fix its configuration or source.",
"screenshotLimit": "This listing can have up to five screenshots. Remove existing images on Workshop before adding more.",
"screenshotUploadFailed": "The package is published, but screenshots could not be uploaded. Add them using View and manage submission.",
"introDescription": "Homarr exports a credential-free copy of the current widget. Add clear listing details so people know what it does before installing it.",
"listingDetails": "Workshop listing",
"listingDetailsDescription": "These details appear publicly alongside the exported widget definition.",
@@ -6154,6 +6177,8 @@
},
"management": {
"metaTitle": "Management",
"collapseSidebar": "Collapse sidebar",
"expandSidebar": "Expand sidebar",
"notFound": {
"title": "Not Found",
"text": "Could not find requested resource",
+12
View File
@@ -466,6 +466,18 @@ export class WorkshopBackend {
}
}
public async addScreenshots(id: string, screenshots: File[]) {
workshopScreenshotsSchema.parse(screenshots);
if (screenshots.length === 0) return;
const current = await this.get(id);
if (current.screenshots.length + screenshots.length > MAX_WORKSHOP_SCREENSHOTS)
throw new Error(`A submission can have up to ${MAX_WORKSHOP_SCREENSHOTS} screenshots`);
await this.pocketBase.collection("submissions").update(id, {
"screenshots+": screenshots,
expectedRevision: current.revision,
});
}
public async update(id: string, input: WorkshopSubmissionInput, screenshotChanges: WorkshopScreenshotChanges = {}) {
const parsed = workshopSubmissionInputSchema.parse(input);
const additions = screenshotChanges.additions ?? [];