diff --git a/apps/docs/docs/management/custom-widgets/trusted-packages.mdx b/apps/docs/docs/management/custom-widgets/trusted-packages.mdx
index 9ee0e70b2..9e0833323 100644
--- a/apps/docs/docs/management/custom-widgets/trusted-packages.mdx
+++ b/apps/docs/docs/management/custom-widgets/trusted-packages.mdx
@@ -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
diff --git a/apps/docs/docs/workshop/index.mdx b/apps/docs/docs/workshop/index.mdx
index cac22a07a..35210c3f4 100644
--- a/apps/docs/docs/workshop/index.mdx
+++ b/apps/docs/docs/workshop/index.mdx
@@ -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.
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-list.tsx
index 2b9320241..600b37217 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-list.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-list.tsx
@@ -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 (
- {t("libraryDescription")}
- {entries.length === 0 && }
+
+
+ {t("libraryDescription")}
+
+
+
+ {entries.length === 0 && (
+
+ )}
- {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 (
- }
- title={{entry.name}}
- badges={
-
- {status}
-
- }
- description={
-
- {t("placements", { count: entry.placementCount })}
-
- }
- actions={
-
- }>
- {tCommon("action.edit")}
-
-
-
- }
- actionsAlignment="center"
- />
- );
- })}
+ {entries.map((entry) => (
+
+ ))}
);
}
+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 (
+ <>
+
+
+
+ }
+ title={{entry.name}}
+ badges={
+
+
+ {status}
+
+ {version && (
+
+ v{version}
+
+ )}
+ {entry.activeVersion && entry.draftVersion !== entry.activeVersion && (
+
+ {t("draftVersion", { version: entry.draftVersion ?? "?" })}
+
+ )}
+
+ }
+ description={
+
+ {entry.description && (
+
+ {entry.description}
+
+ )}
+
+
+ {t("placements", { count: entry.placementCount })}
+
+ {entry.author && (
+
+ {t("byAuthor", { name: entry.author })}
+
+ )}
+ {ownsListing && (
+
+ {t("yourWorkshopListing")}
+
+ )}
+ {entry.workshopUrl && (
+
+ {t("openWorkshop")}
+
+ )}
+
+ {entry.workshop && (
+ }
+ 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")}
+
+ )}
+ {update.error && (
+
+ {update.error.message}
+
+ )}
+ {updateData && !updateData.available && !update.error && (
+
+ {t("upToDate")}
+
+ )}
+
+ }
+ actions={
+
+ }
+ >
+ {tCommon("action.edit")}
+
+
+
+
+
+
+ }
+ actionsAlignment="center"
+ />
+ setOpened(false)} title={t("reviewUpdate")} centered>
+
+
+ {entry.name} · {entry.workshop?.version} → {latest?.version}
+
+
+ {t("updateReviewDescription")}
+
+ {latest?.changelog && (
+
+ {latest.changelog}
+
+ )}
+ {updateData?.hasLocalChanges && (
+ setReplace(event.currentTarget.checked)}
+ label={t("replaceModifiedDraft")}
+ />
+ )}
+ {stage.error && {stage.error.message}}
+
+
+
+
+
+
+ >
+ );
+}
+
export function PackageImportButton() {
const t = useI18n("customWidget.package");
const input = useRef(null);
@@ -180,24 +363,48 @@ function PackageRowActions({ entry }: { entry: RouterOutputs["customWidget"]["pa
}>
{t("connections")}
- }>
- {t("workshopTab")}
+ }
+ >
+ {t("publishWorkshop")}
- } onClick={() => void run(async () => {
- await fork.mutateAsync({ id: entry.id, name: t("forkDefaultName", { name: entry.name }).slice(0, 128) });
- })}>
- {t("fork")}
+ }
+ onClick={() =>
+ void run(async () => {
+ await fork.mutateAsync({ id: entry.id, name: t("forkDefaultName", { name: entry.name }).slice(0, 128) });
+ })
+ }
+ >
+ {t("duplicate")}
- } onClick={() => void run(async () => {
- const draft = await utils.customWidget.package.get.fetch({ id: entry.id });
- downloadPackage(entry.name, draft.source);
- })}>
+ }
+ onClick={() =>
+ void run(async () => {
+ const draft = await utils.customWidget.package.get.fetch({ id: entry.id });
+ downloadPackage(entry.name, draft.source);
+ })
+ }
+ >
{t("exportDraft")}
- {entry.activeArtifactId && } onClick={() => void run(async () => {
- downloadPackage(entry.name, await utils.customWidget.package.export.fetch({ id: entry.id }));
- })}>{t("exportActive")}}
+ {entry.activeArtifactId && (
+ }
+ onClick={() =>
+ void run(async () => {
+ downloadPackage(entry.name, await utils.customWidget.package.export.fetch({ id: entry.id }));
+ })
+ }
+ >
+ {t("exportActive")}
+
+ )}
setManageFiles((value) => !value)} aria-expanded={manageFiles}>
{t("manageFiles")}
-
+
("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 (
-
-
- {t("workshopLifecycle")}
- {update.error && {update.error.message}}
-
- {linked && (
- <>
- {t("upstreamRelease", { version: updateData.origin.version })}
- {updateData.hasLocalChanges && {t("localChanges")}}
- {updateData.available && updateData.latest && (
-
- {t("updateAvailable", { version: updateData.latest.version })}
-
- {updateData.latest.changelog}
-
- setReplace(event.currentTarget.checked)}
- label={t("replaceDraft")}
- />
-
-
- )}
- >
+
+
+ {t("publishWorkshop")}
+
+ {t("publishDescription")}
+
+ {dirty && (
+
+ {t("saveBeforePublish")}
+
)}
-
- {t("publishWorkshop")}
-
-
-
);
@@ -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(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({
)}
-
-
- {t("forkDescription")}
- setForkName(event.currentTarget.value)}
- />
-
-
-
);
}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx
index a803f5420..661a449b5 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx
@@ -13,6 +13,7 @@ import {
Text,
TextInput,
Tooltip,
+ Title,
ActionIcon,
Paper,
} from "@mantine/core";
@@ -115,6 +116,7 @@ export function PackageWorkspace({
else editor.undo();
}}
>
+ {t("workbenchTitle")}
key === query.starter)?.[1];
if (query.sourceItemId && query.starter && Object.hasOwn(widgetReferencePackages, query.starter))
@@ -31,7 +29,6 @@ export default async function NewWidgetPackagePage({
return (
- {t("create")}
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx
index da5961a33..fee8e6d21 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx
@@ -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(null);
const [screenshots, setScreenshots] = useState([]);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const [error, setError] = useState(null);
const [publishedSubmissionId, setPublishedSubmissionId] = useState(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 = ;
@@ -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
{t("publish.manageDescription")}
+ {screenshotError && (
+
+ {t("publish.screenshotUploadFailed")} {screenshotError}
+
+ )}
}>
{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
{t("publish.introTitle")}
- {t("publish.introDescription")}
+ {isPackage ? t("publish.packageDescription") : t("publish.introDescription")}
{!session.user && {t("publish.signInHint")}}
+ {session.user && (
+
+ {t("publish.publishingAs", { name: session.user.name })}
+ {updating && {t("publish.updatingListing")}}
+ {isPackage && origin && !updating && {t("publish.attributedCopy")}}
+
+ )}
@@ -185,6 +260,7 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
setTitle(event.currentTarget.value)}
required
@@ -211,6 +287,38 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
+ {isPackage && (
+
+
+
+
+ {t("publish.releaseDetails")}
+ v{version}
+
+
+
+
+ setVersion(event.currentTarget.value)}
+ required
+ />
+
+
+
+
+ )}
+
{privateSourceNames.length > 0 && (
} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
@@ -225,7 +333,15 @@ export function WorkshopPublishForm({ widget }: { widget: { id: string; name: st
)}
- {definition.isError && {t("publish.error")}}
+ {isPackage && packageDraft.data && !packageSource.success && (
+
+ {t("publish.packageInvalid")}
+
+
+ )}
+ {loadError && {t("publish.error")}}
{error && {error}}
{tCommon("action.cancel")}
-
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/page.tsx
index 3de43da58..b1be86c74 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/page.tsx
@@ -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 (
+
+ );
+ }
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
return ;
diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx
index 49f2af415..6ef72cb1b 100644
--- a/apps/nextjs/src/app/[locale]/manage/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx
@@ -250,7 +250,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const shell = (
-
+
{children}
diff --git a/apps/nextjs/src/components/layout/header/burger.tsx b/apps/nextjs/src/components/layout/header/burger.tsx
index 03ad0851a..5f959c3f3 100644
--- a/apps/nextjs/src/components/layout/header/burger.tsx
+++ b/apps/nextjs/src/components/layout/header/burger.tsx
@@ -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 ;
+ return (
+
+ );
};
diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx
index 3ed4154af..06b40ab15 100644
--- a/apps/nextjs/src/components/layout/navigation-link.tsx
+++ b/apps/nextjs/src/components/layout/navigation-link.tsx
@@ -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 ? : ;
@@ -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,
+
+
+ {props.icon}
+
+ ,
+ );
+ }
const link = props.external ? (
) : (
-
+
);
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"],
+ ,
+ );
+ }
+
const nav = (
{props.items.map((item) => (
diff --git a/apps/nextjs/src/components/layout/navigation.tsx b/apps/nextjs/src/components/layout/navigation.tsx
index dbe7b987d..117a6088b 100644
--- a/apps/nextjs/src/components/layout/navigation.tsx
+++ b/apps/nextjs/src/components/layout/navigation.tsx
@@ -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 (
-
+
{headerSection && {headerSection}}
{footerSection && {footerSection}}
-
+
);
};
diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx
index bf86ca341..c29a2f60d 100644
--- a/apps/nextjs/src/components/layout/shell.tsx
+++ b/apps/nextjs/src/components/layout/shell.tsx
@@ -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 (
+
+ {enabled && (
+
+ {!compact && (
+
+ {t("metaTitle")}
+
+ )}
+
+
+ {compact ? : }
+
+
+
+ )}
+ {children}
+
+ );
}
export const ClientShell = ({
hasHeader = true,
hasNavigation = true,
+ management = false,
children,
}: PropsWithChildren) => {
- 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 (
- 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}
-
+
+ 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}
+
+
);
};
diff --git a/packages/api/src/router/custom-widget/package/management.ts b/packages/api/src/router/custom-widget/package/management.ts
index f1242e6e3..195dfdf93 100644
--- a/packages/api/src/router/custom-widget/package/management.ts
+++ b/packages/api/src/router/custom-widget/package/management.ts
@@ -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();
@@ -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),
diff --git a/packages/api/src/router/custom-widget/package/portable-collection-origin.ts b/packages/api/src/router/custom-widget/package/portable-collection-origin.ts
index 1768a3e14..d5797c17d 100644
--- a/packages/api/src/router/custom-widget/package/portable-collection-origin.ts
+++ b/packages/api/src/router/custom-widget/package/portable-collection-origin.ts
@@ -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;
diff --git a/packages/api/src/router/custom-widget/package/workshop-client.ts b/packages/api/src/router/custom-widget/package/workshop-client.ts
index 554b2cce1..eae9196b8 100644
--- a/packages/api/src/router/custom-widget/package/workshop-client.ts
+++ b/packages/api/src/router/custom-widget/package/workshop-client.ts
@@ -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);
diff --git a/packages/api/src/router/custom-widget/package/workshop.ts b/packages/api/src/router/custom-widget/package/workshop.ts
index 294c20f0f..5db540552 100644
--- a/packages/api/src/router/custom-widget/package/workshop.ts
+++ b/packages/api/src/router/custom-widget/package/workshop.ts
@@ -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,
+ };
+ });
}),
};
diff --git a/packages/custom-widgets/src/package/collection.ts b/packages/custom-widgets/src/package/collection.ts
index 2f234c745..316e4fa60 100644
--- a/packages/custom-widgets/src/package/collection.ts
+++ b/packages/custom-widgets/src/package/collection.ts
@@ -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),
});
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index cfc11ca41..40901454b 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -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",
diff --git a/packages/workshop/src/backend.ts b/packages/workshop/src/backend.ts
index 72fee9ea9..638441201 100644
--- a/packages/workshop/src/backend.ts
+++ b/packages/workshop/src/backend.ts
@@ -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 ?? [];