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={ - - - - - } - 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 && ( + + )} + {update.error && ( + + {update.error.message} + + )} + {updateData && !updateData.available && !update.error && ( + + {t("upToDate")} + + )} + + } + actions={ + + + + + + + + } + 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")} - - -