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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user