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

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