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>
);
};