fix: simplify widget package publishing and navigation
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -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")}
|
||||
|
||||
+17
-178
@@ -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>
|
||||
|
||||
+135
-24
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShell } from "@mantine/core";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ActionIcon, AppShell, AppShellNavbar, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { useMediaQuery, useLocalStorage } from "@mantine/hooks";
|
||||
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useOptionalBackgroundProps } from "./background";
|
||||
import { appShellHeaderHeight } from "./constants";
|
||||
@@ -12,40 +17,90 @@ import { navigationCollapsedAtom } from "./header/burger";
|
||||
interface ClientShellProps {
|
||||
hasHeader?: boolean;
|
||||
hasNavigation?: boolean;
|
||||
management?: boolean;
|
||||
}
|
||||
|
||||
const NavigationContext = createContext({ enabled: false, compact: false, toggle: () => undefined as void });
|
||||
export const useCompactNavigation = () => useContext(NavigationContext).compact;
|
||||
|
||||
export function NavigationPanel({ children }: PropsWithChildren) {
|
||||
const { enabled, compact, toggle } = useContext(NavigationContext);
|
||||
const t = useI18n("management");
|
||||
const label = compact ? t("expandSidebar") : t("collapseSidebar");
|
||||
return (
|
||||
<AppShellNavbar p={compact ? "xs" : "md"}>
|
||||
{enabled && (
|
||||
<Group justify={compact ? "center" : "space-between"} mb="sm" visibleFrom="sm">
|
||||
{!compact && (
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{t("metaTitle")}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip label={label} position="right">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={toggle} aria-label={label}>
|
||||
{compact ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
{children}
|
||||
</AppShellNavbar>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClientShell = ({
|
||||
hasHeader = true,
|
||||
hasNavigation = true,
|
||||
management = false,
|
||||
children,
|
||||
}: PropsWithChildren<ClientShellProps>) => {
|
||||
const collapsed = useAtomValue(navigationCollapsedAtom);
|
||||
const [collapsed, setCollapsed] = useAtom(navigationCollapsedAtom);
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
if (management) setCollapsed(true);
|
||||
}, [management, pathname, setCollapsed]);
|
||||
const desktop = useMediaQuery("(min-width: 48em)");
|
||||
const [preferredCompact, setPreferredCompact] = useLocalStorage({
|
||||
key: "homarr.management-sidebar-compact",
|
||||
defaultValue: false,
|
||||
});
|
||||
const [workbenchOverride, setWorkbenchOverride] = useState<{ path: string; compact: boolean } | null>(null);
|
||||
const isWorkbench = /\/manage\/custom-widgets\/packages\/(?!collections(?:\/|$))[^/]+\/?$/u.test(pathname);
|
||||
let compact = preferredCompact;
|
||||
if (isWorkbench) compact = workbenchOverride?.path === pathname ? workbenchOverride.compact : true;
|
||||
compact = management && Boolean(desktop) && compact;
|
||||
const toggle = () => {
|
||||
if (isWorkbench) setWorkbenchOverride({ path: pathname, compact: !compact });
|
||||
else setPreferredCompact(!compact);
|
||||
};
|
||||
const backgroundProps = useOptionalBackgroundProps();
|
||||
const { headerPreferences } = useSettings();
|
||||
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
|
||||
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
|
||||
// background image only covers as much height as the content needs, so collapsing a
|
||||
// container short enough leaves flat page background showing below it instead of the
|
||||
// background continuing to the bottom of the viewport.
|
||||
mih={backgroundProps.bg ? "100dvh" : undefined}
|
||||
header={hasHeader ? { height: headerHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: collapsed },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
padding="md"
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
<NavigationContext.Provider value={{ enabled: management, compact, toggle }}>
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
|
||||
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
|
||||
// background image only covers as much height as the content needs, so collapsing a
|
||||
// container short enough leaves flat page background showing below it instead of the
|
||||
// background continuing to the bottom of the viewport.
|
||||
mih={backgroundProps.bg ? "100dvh" : undefined}
|
||||
header={hasHeader ? { height: headerHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
width: compact ? 64 : 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: collapsed },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
padding="md"
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,8 @@ import { packagePreviewProcedures } from "./preview-procedures";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetInstallations, items } from "@homarr/db/schema";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { customWidgetArtifacts, customWidgetInstallations, items } from "@homarr/db/schema";
|
||||
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
|
||||
|
||||
import { createTRPCRouter } from "../../../trpc";
|
||||
@@ -30,6 +30,7 @@ import { getOrBuildWidgetArtifact, packageTransferProcedures } from "./transfer"
|
||||
import { withWidgetInstallationLock } from "./coordination";
|
||||
import { publishWidgetPackageChange } from "./events";
|
||||
import { packageWorkshopProcedures } from "./workshop";
|
||||
import { getWidgetWorkshopUrl, readWidgetWorkshopOrigin } from "./workshop-client";
|
||||
import { setWidgetPackageEnabled } from "./enabled";
|
||||
import { packageWebhookProcedures } from "./webhook-procedures";
|
||||
import { packageArtifactReviewProcedures } from "./artifact-review";
|
||||
@@ -67,7 +68,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const [rows, placements] = await Promise.all([
|
||||
ctx.db.query.customWidgetInstallations.findMany({ columns: { draft: false, previousSnapshot: false } }),
|
||||
ctx.db.query.customWidgetInstallations.findMany({ columns: { previousSnapshot: false } }),
|
||||
ctx.db.query.items.findMany({ where: eq(items.kind, "customApi"), columns: { options: true } }),
|
||||
]);
|
||||
const counts = new Map<string, number>();
|
||||
@@ -75,11 +76,30 @@ export const customWidgetPackageRouter = createTRPCRouter({
|
||||
const options = parsePackagePlacement(placement.options);
|
||||
if (options) counts.set(options.definitionId, (counts.get(options.definitionId) ?? 0) + 1);
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
bindings: parseBindings(row.bindings),
|
||||
placementCount: counts.get(row.id) ?? 0,
|
||||
}));
|
||||
const artifactIds = rows.flatMap((row) => (row.activeArtifactId ? [row.activeArtifactId] : []));
|
||||
const artifacts =
|
||||
artifactIds.length > 0
|
||||
? await ctx.db.query.customWidgetArtifacts.findMany({
|
||||
where: inArray(customWidgetArtifacts.id, artifactIds),
|
||||
columns: { id: true, version: true },
|
||||
})
|
||||
: [];
|
||||
const versions = new Map(artifacts.map((artifact) => [artifact.id, artifact.version]));
|
||||
return rows.map(({ draft, ...row }) => {
|
||||
const source = customWidgetPackageSchema.safeParse(readPackageDraft(draft));
|
||||
const origin = readWidgetWorkshopOrigin(row.origin);
|
||||
return {
|
||||
...row,
|
||||
bindings: parseBindings(row.bindings),
|
||||
placementCount: counts.get(row.id) ?? 0,
|
||||
activeVersion: versions.get(row.activeArtifactId ?? "") ?? null,
|
||||
draftVersion: source.data?.manifest.version ?? null,
|
||||
description: source.data?.manifest.description ?? "",
|
||||
author: origin?.authorName || source.data?.manifest.author || "",
|
||||
workshop: origin,
|
||||
workshopUrl: origin ? getWidgetWorkshopUrl(origin.submissionId) : null,
|
||||
};
|
||||
});
|
||||
}),
|
||||
get: admin
|
||||
.meta({
|
||||
@@ -95,6 +115,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
|
||||
const { previousSnapshot: _snapshot, draft: _draft, ...installation } = row;
|
||||
return {
|
||||
...installation,
|
||||
workshop: readWidgetWorkshopOrigin(row.origin),
|
||||
source: readPackageDraft(row.draft),
|
||||
draftDigest: packageDigest(row.draft),
|
||||
bindings: parseBindings(row.bindings),
|
||||
|
||||
@@ -48,16 +48,19 @@ export function preserveWidgetCollectionOrigin(raw: string | null, nextOrigin: o
|
||||
export function readPortableWidgetOrigin(raw: string | null): WidgetPortableOrigin | undefined {
|
||||
const value = readOrigin(raw);
|
||||
if (!value) return undefined;
|
||||
const { collection: _collection, ...origin } = value;
|
||||
const { collection: _collection, authorName: _authorName, ...origin } = value;
|
||||
const direct = widgetPortableOriginSchema.safeParse(origin);
|
||||
if (direct.success) return direct.data;
|
||||
if (typeof value.forkedFrom !== "string") return undefined;
|
||||
if (value.kind !== undefined || typeof value.forkedFrom !== "string") return undefined;
|
||||
const upstream = readPortableWidgetOrigin(typeof value.origin === "string" ? value.origin : null);
|
||||
let workshopUpstream;
|
||||
if (upstream?.kind === "workshop") workshopUpstream = upstream;
|
||||
if (upstream?.kind === "fork") workshopUpstream = upstream.upstream;
|
||||
const parsed = widgetPortableOriginSchema.safeParse({
|
||||
kind: "fork",
|
||||
packageId: value.forkedFrom,
|
||||
artifactDigest: value.artifact ?? undefined,
|
||||
upstream: upstream?.kind === "workshop" ? upstream : undefined,
|
||||
upstream: workshopUpstream,
|
||||
});
|
||||
if (parsed.success) return parsed.data;
|
||||
return undefined;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/server";
|
||||
import type { WorkshopPackageRelease } from "@homarr/workshop/package-releases";
|
||||
import { parseWorkshopPackageRelease } from "@homarr/workshop/package-releases";
|
||||
import { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
@@ -24,9 +25,13 @@ export const widgetWorkshopOriginSchema = z.object({
|
||||
sourceDigest: z.string(),
|
||||
artifactDigest: z.string(),
|
||||
author: z.string(),
|
||||
authorName: z.string().optional(),
|
||||
forkedFrom: z.string(),
|
||||
});
|
||||
|
||||
export const getWidgetWorkshopUrl = (submissionId: string) =>
|
||||
`${urls.workshopWebUrl}/${encodeURIComponent(submissionId)}`;
|
||||
|
||||
export function readWidgetWorkshopOrigin(raw: string | null) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
@@ -41,27 +46,31 @@ export function readWidgetWorkshopOrigin(raw: string | null) {
|
||||
export async function getWidgetWorkshopRelease(releaseId: string) {
|
||||
return withWorkshopError(async () => {
|
||||
const release = await widgetWorkshop.packages.get(releaseId, AbortSignal.timeout(15_000));
|
||||
const parsed = parseWorkshopPackageRelease(release);
|
||||
if (
|
||||
packageDigest(parsed.source) !== release.sourceDigest ||
|
||||
parsed.source.manifest.id !== release.packageId ||
|
||||
parsed.source.manifest.version !== release.version
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
|
||||
}
|
||||
if (parsed.artifact) {
|
||||
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
|
||||
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
release,
|
||||
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
|
||||
workshopUrl: `${urls.workshopWebUrl}/${encodeURIComponent(release.submission)}`,
|
||||
};
|
||||
return describeWidgetWorkshopRelease(release);
|
||||
});
|
||||
}
|
||||
|
||||
export function describeWidgetWorkshopRelease(release: WorkshopPackageRelease) {
|
||||
const parsed = parseWorkshopPackageRelease(release);
|
||||
if (
|
||||
packageDigest(parsed.source) !== release.sourceDigest ||
|
||||
parsed.source.manifest.id !== release.packageId ||
|
||||
parsed.source.manifest.version !== release.version
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Workshop release metadata does not match its package" });
|
||||
}
|
||||
if (parsed.artifact) {
|
||||
assertWidgetArtifactIntegrity(parsed.artifact, parsed.source);
|
||||
if (parsed.artifact.digest !== release.artifactDigest) throw new Error("Release artifact checksum mismatch");
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
release,
|
||||
origin: { kind: "workshop" as const, workshopApiUrl: urls.workshopApiUrl, ...parsed.origin },
|
||||
workshopUrl: getWidgetWorkshopUrl(release.submission),
|
||||
};
|
||||
}
|
||||
|
||||
export function authenticatedWidgetWorkshop(token: string) {
|
||||
const backend = new WorkshopBackend(urls.workshopApiUrl);
|
||||
backend.pocketBase.authStore.save(token);
|
||||
|
||||
@@ -3,18 +3,19 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
|
||||
import { customWidgetPackageSchema, widgetPackageManifestSchema } from "@homarr/custom-widgets/package";
|
||||
import type { CustomWidgetArtifact } from "@homarr/custom-widgets/package";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { customWidgetInstallations } from "@homarr/db/schema";
|
||||
import { preserveWidgetCollectionOrigin } from "./portable-collection-origin";
|
||||
import { preserveWidgetCollectionOrigin, readPortableWidgetOrigin } from "./portable-collection-origin";
|
||||
|
||||
import { persistPackageArtifact } from "./preview-store";
|
||||
import { withWidgetInstallationLock } from "./coordination";
|
||||
import { withWidgetArtifactReferenceLock } from "./artifact-references";
|
||||
import { getArtifact, getInstallation, packageDigest } from "./records";
|
||||
import { getArtifact, getInstallation, packageDigest, readPackageDraft } from "./records";
|
||||
import {
|
||||
authenticatedWidgetWorkshop,
|
||||
describeWidgetWorkshopRelease,
|
||||
getWidgetWorkshopRelease,
|
||||
readWidgetWorkshopOrigin,
|
||||
widgetWorkshop,
|
||||
@@ -91,7 +92,7 @@ export const packageWorkshopProcedures = {
|
||||
widgetWorkshop.packages.list(origin.submissionId, AbortSignal.timeout(15_000)),
|
||||
);
|
||||
const latest = releases[0];
|
||||
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
|
||||
const draft = customWidgetPackageSchema.safeParse(readPackageDraft(installation.draft));
|
||||
let activeVersion: string | null = null;
|
||||
if (installation.activeArtifactId)
|
||||
activeVersion = (await getArtifact(ctx, installation.activeArtifactId)).source.manifest.version;
|
||||
@@ -153,7 +154,11 @@ export const packageWorkshopProcedures = {
|
||||
.input(
|
||||
idInput.extend({
|
||||
token: z.string().min(1).max(20_000),
|
||||
mode: z.enum(["new", "update", "fork"]),
|
||||
mode: z.enum(["auto", "new", "update", "fork"]).default("auto"),
|
||||
title: z.string().trim().min(3).max(100).optional(),
|
||||
description: z.string().max(2048).optional(),
|
||||
version: widgetPackageManifestSchema.shape.version.optional(),
|
||||
expectedDraftDigest: z.string().min(1).optional(),
|
||||
submissionId: z.string().optional(),
|
||||
expectedRevision: z.number().int().positive().optional(),
|
||||
forkedFrom: z.string().optional(),
|
||||
@@ -161,56 +166,89 @@ export const packageWorkshopProcedures = {
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const installation = await getInstallation(ctx, input.id);
|
||||
const source = customWidgetPackageSchema.parse(JSON.parse(installation.draft));
|
||||
const origin = readWidgetWorkshopOrigin(installation.origin);
|
||||
let submissionId: string | undefined;
|
||||
let forkedFrom: string | undefined;
|
||||
if (input.mode === "update") {
|
||||
submissionId = input.submissionId ?? origin?.submissionId;
|
||||
if (!submissionId || !input.expectedRevision)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "An update requires its Workshop submission and current revision",
|
||||
});
|
||||
} else if (input.mode === "fork") {
|
||||
forkedFrom = input.forkedFrom ?? origin?.releaseId;
|
||||
if (!forkedFrom)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
|
||||
const original = await getWidgetWorkshopRelease(forkedFrom);
|
||||
if (original.source.manifest.id === source.manifest.id)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
|
||||
}
|
||||
let artifact: CustomWidgetArtifact | undefined;
|
||||
if (installation.activeArtifactId) {
|
||||
const active = await getArtifact(ctx, installation.activeArtifactId);
|
||||
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
|
||||
}
|
||||
const release = await withWorkshopError(() =>
|
||||
authenticatedWidgetWorkshop(input.token).packages.publish({
|
||||
source,
|
||||
artifact,
|
||||
submissionId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
forkedFrom,
|
||||
changelog: input.changelog,
|
||||
title: installation.name,
|
||||
description: source.manifest.description,
|
||||
}),
|
||||
);
|
||||
const published = await getWidgetWorkshopRelease(release.id);
|
||||
await ctx.db
|
||||
.update(customWidgetInstallations)
|
||||
.set({
|
||||
origin: JSON.stringify(preserveWidgetCollectionOrigin(installation.origin, published.origin)),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(customWidgetInstallations.id, input.id));
|
||||
return {
|
||||
releaseId: release.id,
|
||||
submissionId: release.submission,
|
||||
version: release.version,
|
||||
workshopUrl: published.workshopUrl,
|
||||
};
|
||||
return withWidgetInstallationLock(input.id, async () => {
|
||||
const installation = await getInstallation(ctx, input.id);
|
||||
if (input.expectedDraftDigest && packageDigest(installation.draft) !== input.expectedDraftDigest)
|
||||
throw new TRPCError({ code: "CONFLICT", message: "The saved draft changed. Reload it before publishing." });
|
||||
const source = customWidgetPackageSchema.parse(readPackageDraft(installation.draft));
|
||||
const origin = readWidgetWorkshopOrigin(installation.origin);
|
||||
const backend = authenticatedWidgetWorkshop(input.token);
|
||||
const user = await withWorkshopError(() => backend.refreshAuth());
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "Sign in to Workshop before publishing." });
|
||||
let submissionId: string | undefined;
|
||||
let forkedFrom: string | undefined;
|
||||
let expectedRevision = input.expectedRevision;
|
||||
if (input.mode === "auto") {
|
||||
if (!input.expectedDraftDigest)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Review the saved draft before publishing." });
|
||||
const portable = readPortableWidgetOrigin(installation.origin);
|
||||
if (origin?.author === user.id) {
|
||||
const listing = await withWorkshopError(() => backend.get(origin.submissionId));
|
||||
submissionId = listing.id;
|
||||
expectedRevision = listing.revision;
|
||||
} else if (origin) {
|
||||
forkedFrom = origin.releaseId;
|
||||
} else if (portable?.kind === "fork" && portable.upstream) {
|
||||
const upstream = readWidgetWorkshopOrigin(JSON.stringify(portable.upstream));
|
||||
forkedFrom = upstream?.releaseId;
|
||||
}
|
||||
if (forkedFrom) {
|
||||
const original = await getWidgetWorkshopRelease(forkedFrom);
|
||||
if (original.source.manifest.id === source.manifest.id) source.manifest.id = `widget-${createId()}`;
|
||||
}
|
||||
source.manifest.author = user.name;
|
||||
source.manifest.name = input.title ?? installation.name;
|
||||
source.manifest.description = input.description ?? source.manifest.description;
|
||||
source.manifest.version = input.version ?? source.manifest.version;
|
||||
} else if (input.mode === "update") {
|
||||
submissionId = input.submissionId ?? origin?.submissionId;
|
||||
if (!submissionId || !expectedRevision)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "An update requires its Workshop submission and current revision",
|
||||
});
|
||||
} else if (input.mode === "fork") {
|
||||
forkedFrom = input.forkedFrom ?? origin?.releaseId;
|
||||
if (!forkedFrom)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose the original release to publish a fork" });
|
||||
const original = await getWidgetWorkshopRelease(forkedFrom);
|
||||
if (original.source.manifest.id === source.manifest.id)
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A fork must have its own package identity" });
|
||||
}
|
||||
let artifact: CustomWidgetArtifact | undefined;
|
||||
if (installation.activeArtifactId) {
|
||||
const active = await getArtifact(ctx, installation.activeArtifactId);
|
||||
if (packageDigest(active.source) === packageDigest(source)) artifact = active.artifact;
|
||||
}
|
||||
const release = await withWorkshopError(() =>
|
||||
backend.packages.publish({
|
||||
source,
|
||||
artifact,
|
||||
submissionId,
|
||||
expectedRevision,
|
||||
forkedFrom,
|
||||
changelog: input.changelog,
|
||||
title: input.title ?? installation.name,
|
||||
description: input.description ?? source.manifest.description,
|
||||
}),
|
||||
);
|
||||
const published = describeWidgetWorkshopRelease(release);
|
||||
await ctx.db
|
||||
.update(customWidgetInstallations)
|
||||
.set({
|
||||
origin: JSON.stringify(
|
||||
preserveWidgetCollectionOrigin(installation.origin, { ...published.origin, authorName: user.name }),
|
||||
),
|
||||
draft: JSON.stringify(source),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(customWidgetInstallations.id, input.id));
|
||||
return {
|
||||
releaseId: release.id,
|
||||
submissionId: release.submission,
|
||||
version: release.version,
|
||||
workshopUrl: published.workshopUrl,
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export const widgetPortableWorkshopOriginSchema = z.strictObject({
|
||||
releaseId: z.string().max(128),
|
||||
version: z.string().max(128),
|
||||
sourceDigest: digest,
|
||||
artifactDigest: digest,
|
||||
artifactDigest: digest.or(z.literal("")),
|
||||
author: z.string().max(256),
|
||||
forkedFrom: z.string().max(128),
|
||||
});
|
||||
|
||||
@@ -1191,6 +1191,17 @@
|
||||
"savedPlaceholder": "••••••• (saved, leave empty to keep)"
|
||||
},
|
||||
"package": {
|
||||
"workbenchTitle": "Widget editor",
|
||||
"publishDescription": "Add a title, description, and screenshots using the Workshop publishing form. Your Workshop account supplies the attribution.",
|
||||
"saveBeforePublish": "Save your draft before publishing.",
|
||||
"draftVersion": "Draft v{version}",
|
||||
"byAuthor": "By {name}",
|
||||
"yourWorkshopListing": "Your Workshop listing",
|
||||
"upToDate": "No newer Workshop release",
|
||||
"reviewUpdate": "Review update",
|
||||
"updateReviewDescription": "Load this release into the editor for review. Your active widgets keep running until you activate the update. Connections and placement settings are preserved.",
|
||||
"replaceModifiedDraft": "Replace my modified saved draft with this release",
|
||||
"duplicate": "Duplicate",
|
||||
"title": "Widget packages",
|
||||
"create": "Create package",
|
||||
"library": "Packages",
|
||||
@@ -1379,7 +1390,7 @@
|
||||
"changelog": "Release notes",
|
||||
"publishAcknowledgement": "I reviewed the complete saved source for secrets and local information, and I have permission to share this code and its dependencies publicly.",
|
||||
"published": "Published release {version}.",
|
||||
"openWorkshop": "Open Workshop page",
|
||||
"openWorkshop": "View on Workshop",
|
||||
"manifestFile": "widget.json",
|
||||
"filePathExample": "components/Status.tsx",
|
||||
"serverUrlExample": "http://sonarr:8989",
|
||||
@@ -1564,7 +1575,7 @@
|
||||
"useDescription": "Save a draft to keep your work. Activate it when you want boards to use that code, then add it to a board below. One package can appear on several boards.",
|
||||
"previewSavesFirst": "Your first preview also saves a draft. It does not add or change dashboard tiles.",
|
||||
"workshopTab": "Workshop",
|
||||
"workshopDescription": "Share your finished widget, publish a new release, or review upstream updates. Local connections and credentials stay on your Homarr instance.",
|
||||
"workshopDescription": "Share your saved widget using the Workshop publishing form. Check for updates and view installed versions in the Packages library.",
|
||||
"releaseTools": "Release details, disable and rollback",
|
||||
"chooseConnection": "Choose a connection or Homarr integration",
|
||||
"noCompatibleConnections": "No compatible connections yet. Create one below, then refresh this list.",
|
||||
@@ -1579,7 +1590,7 @@
|
||||
"runtimeLogs": "Build and preview errors appear here. Server output is included in Homarr logs under custom-widget-package. The active release process can be inspected below.",
|
||||
"buildState": "Preview build: {state}",
|
||||
"actionsMenu": "Widget package actions",
|
||||
"forkDefaultName": "{name} fork",
|
||||
"forkDefaultName": "{name} copy",
|
||||
"previewBuilding": "Building preview…",
|
||||
"previewBuildReady": "Preview build ready",
|
||||
"previewBuildFailed": "Preview build failed",
|
||||
@@ -1675,6 +1686,18 @@
|
||||
"publish": {
|
||||
"title": "Publish to Workshop",
|
||||
"introTitle": "Share this widget with the community",
|
||||
"packageDescription": "Share the saved package source. Connections, credentials, and stored widget data stay in your Homarr instance. Review code and fixtures before publishing.",
|
||||
"publishingAs": "Publishing as {name}",
|
||||
"updatingListing": "A new release will update your existing Workshop listing.",
|
||||
"attributedCopy": "Your copy will have its own listing and retain a link to the original author’s release.",
|
||||
"releaseDetails": "Release details",
|
||||
"version": "Version",
|
||||
"releaseNotes": "Release notes (optional)",
|
||||
"releaseNotesDescription": "Tell existing users what changed in this release.",
|
||||
"updateAction": "Publish update",
|
||||
"packageInvalid": "This saved package needs repair before it can be published. Open the editor to fix its configuration or source.",
|
||||
"screenshotLimit": "This listing can have up to five screenshots. Remove existing images on Workshop before adding more.",
|
||||
"screenshotUploadFailed": "The package is published, but screenshots could not be uploaded. Add them using View and manage submission.",
|
||||
"introDescription": "Homarr exports a credential-free copy of the current widget. Add clear listing details so people know what it does before installing it.",
|
||||
"listingDetails": "Workshop listing",
|
||||
"listingDetailsDescription": "These details appear publicly alongside the exported widget definition.",
|
||||
@@ -6154,6 +6177,8 @@
|
||||
},
|
||||
"management": {
|
||||
"metaTitle": "Management",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"expandSidebar": "Expand sidebar",
|
||||
"notFound": {
|
||||
"title": "Not Found",
|
||||
"text": "Could not find requested resource",
|
||||
|
||||
@@ -466,6 +466,18 @@ export class WorkshopBackend {
|
||||
}
|
||||
}
|
||||
|
||||
public async addScreenshots(id: string, screenshots: File[]) {
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
if (screenshots.length === 0) return;
|
||||
const current = await this.get(id);
|
||||
if (current.screenshots.length + screenshots.length > MAX_WORKSHOP_SCREENSHOTS)
|
||||
throw new Error(`A submission can have up to ${MAX_WORKSHOP_SCREENSHOTS} screenshots`);
|
||||
await this.pocketBase.collection("submissions").update(id, {
|
||||
"screenshots+": screenshots,
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
}
|
||||
|
||||
public async update(id: string, input: WorkshopSubmissionInput, screenshotChanges: WorkshopScreenshotChanges = {}) {
|
||||
const parsed = workshopSubmissionInputSchema.parse(input);
|
||||
const additions = screenshotChanges.additions ?? [];
|
||||
|
||||
Reference in new issue
Block a user