feat(workshop): rebuild install and publish as pages
Replace the stacked install modals (browse -> details -> import review) with page-based flows so the primary action is always reachable: - /manage/custom-widgets/workshop browses submissions as a card grid with large previews, sitting in a tab next to the installed widgets - /manage/custom-widgets/workshop/[id] merges the listing, security review and source setup onto one page with a sticky install footer - /manage/custom-widgets/publish/[id] replaces the publish modal - /manage/workshop redirects; its navbar entry is gone - the Custom CSS picker collapses from three modals to one Extract useCustomWidgetImport and ImportReviewContent so the Workshop page and the file/clipboard dialog share one import pipeline. Votes now show the viewer's own choice filled Reddit-style, backed by a new user-votes query, and screenshots get a zoomable lightbox. Rework the installed list onto ManageCollectionItem: show the widget description, replace the cryptic "JSX - 2" badge with plain request and origin facts, and swap the toggle and bare pencil for a status badge, a labelled Edit button and a menu entry for enable/disable. Fixes found while reviewing: - importing could run twice and duplicate a widget; guard on pending and succeeded, and return to the installed list instead of the heavy edit form - a background refetch of the submission (window focus, or the invalidation a vote triggers) reset the source setup and silently wiped part-typed credentials; key the reset on the sources, not the object - breadcrumbs raised MISSING_MESSAGE for unmapped dynamic segments; fall back to the raw segment via t.has - publishing had no in-flight guard and could post duplicate listings - tooltips on disabled vote and report controls never fired, making the sign-in hint unreachable
This commit is contained in:
38 files changed
+2303
-1700
No files matched your search
@@ -9,6 +9,8 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
@@ -21,9 +23,9 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -40,8 +42,8 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const [publishOpened, publishControls] = useDisclosure(false);
|
||||
const [migrationOpened, migrationControls] = useDisclosure(false);
|
||||
const migrationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
@@ -147,6 +149,26 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
duplicateMutation.mutate(
|
||||
{ id: widget.id },
|
||||
@@ -202,6 +224,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Dropdown>
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Menu.Item
|
||||
onClick={handleToggleEnabled}
|
||||
leftSection={widget.enabled ? <IconPlayerPause {...iconProps} /> : <IconPlayerPlay {...iconProps} />}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
{widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
onClick={handleDuplicate}
|
||||
leftSection={<IconCopy {...iconProps} />}
|
||||
@@ -212,7 +242,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/publish/${widget.id}`}
|
||||
leftSection={<IconBuildingStore {...iconProps} />}
|
||||
>
|
||||
{t("action.publishWorkshop")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
@@ -254,7 +288,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
|
||||
+11
-5
@@ -19,12 +19,18 @@ describe("legacy custom widget migration UI", () => {
|
||||
});
|
||||
|
||||
it("replaces the legacy record through migration and refreshes every consumer", () => {
|
||||
// The import pipeline is shared by the dialog and the Workshop install page.
|
||||
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
|
||||
|
||||
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
|
||||
expect(dialog).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(dialog).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
|
||||
expect(importer).toMatch(
|
||||
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
|
||||
);
|
||||
expect(importer).toContain("utils.customWidget.list.invalidate()");
|
||||
expect(importer).toContain("utils.customWidget.available.invalidate()");
|
||||
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
|
||||
expect(dialog).toContain("useCustomWidgetImport({");
|
||||
expect(dialog).toContain("legacyId,");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
|
||||
@@ -31,161 +29,69 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{definitions.map((def) => (
|
||||
<CustomWidgetCard key={def.id} widget={def} />
|
||||
<ManageCollectionList ariaLabel={t("page.list.title")}>
|
||||
{definitions.map((definition) => (
|
||||
<CustomWidgetRow key={definition.id} widget={definition} />
|
||||
))}
|
||||
</Stack>
|
||||
</ManageCollectionList>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
|
||||
|
||||
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleMutation.mutate(
|
||||
{ id: widget.id, enabled: !widget.enabled },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.widget.customApi.getData.cancel();
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: widget.enabled ? t("action.disable") : t("action.enable"),
|
||||
message: t("notification.toggleError"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const origins = [...new Set(widget.sources.map((source) => source.origin))];
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
<ManageCollectionItem
|
||||
leading={
|
||||
<Avatar
|
||||
size={40}
|
||||
radius="sm"
|
||||
src={widget.iconUrl}
|
||||
color={widget.valid ? "pink" : "red"}
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
alt=""
|
||||
>
|
||||
<IconApi size={20} stroke={1.5} />
|
||||
</Avatar>
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
{widget.iconUrl ? (
|
||||
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<Avatar size={36} radius="sm" color="red">
|
||||
<IconApi size={18} />
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
title={
|
||||
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
}
|
||||
badges={<CustomWidgetStatusBadges widget={widget} />}
|
||||
description={
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{widget.description || t("page.list.noDescription")}
|
||||
</Text>
|
||||
}
|
||||
metadata={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("page.list.requestCount", { count: widget.requestCount })}
|
||||
</Text>
|
||||
{origins.map((origin) => (
|
||||
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
|
||||
· {origin}
|
||||
</Text>
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge
|
||||
color="red"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
}
|
||||
actions={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{widget.valid && (
|
||||
<>
|
||||
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("action.edit")}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/manage/custom-widgets/edit/${widget.id}`}
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{
|
||||
@@ -197,7 +103,65 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything that decides whether this widget can actually run right now. */
|
||||
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
|
||||
if (widget.migrationRequired) {
|
||||
return (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.valid) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={widget.validationIssues
|
||||
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
|
||||
.join("\n")}
|
||||
multiline
|
||||
maw={520}
|
||||
>
|
||||
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.invalidDefinition")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{widget.enabled ? (
|
||||
<Badge color="green" size="sm" variant="light">
|
||||
{t("page.list.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label={t("page.list.disabledDescription")}>
|
||||
<Badge color="gray" size="sm" variant="light">
|
||||
{t("page.list.disabled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{widget.missingSecrets.length > 0 && (
|
||||
<Tooltip
|
||||
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
|
||||
multiline
|
||||
maw={320}
|
||||
>
|
||||
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
const tabs = {
|
||||
installed: "/manage/custom-widgets",
|
||||
workshop: "/manage/custom-widgets/workshop",
|
||||
} as const;
|
||||
|
||||
/** Switches between the widgets you have installed and the community Workshop. */
|
||||
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
|
||||
const t = useScopedI18n("customWidget.page.tabs");
|
||||
|
||||
return (
|
||||
<Tabs value={tabs[active]}>
|
||||
<Tabs.List aria-label={t("ariaLabel")}>
|
||||
<Tabs.Tab
|
||||
value={tabs.installed}
|
||||
leftSection={<IconApi size={16} stroke={1.5} />}
|
||||
renderRoot={(props) => <Link href={tabs.installed} {...props} />}
|
||||
>
|
||||
{t("installed")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value={tabs.workshop}
|
||||
leftSection={<IconBuildingStore size={16} stroke={1.5} />}
|
||||
renderRoot={(props) => <Link href={tabs.workshop} {...props} />}
|
||||
>
|
||||
{t("workshop")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -10,9 +10,9 @@ import { Link } from "@homarr/ui";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
import { CustomWidgetTabs } from "./_custom-widget-tabs";
|
||||
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
|
||||
|
||||
export default async function CustomWidgetsPage() {
|
||||
const session = await auth();
|
||||
@@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() {
|
||||
>
|
||||
{t("action.downloadSkill")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopInstallButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</Group>
|
||||
}
|
||||
toolbar={<CustomWidgetTabs active="installed" />}
|
||||
>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetList definitions={definitions} />
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconBuildingStore,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconPhoto,
|
||||
IconShieldCheck,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
|
||||
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
publishWorkshopDefinition,
|
||||
serializeWorkshopDefinition,
|
||||
} 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 } }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
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 privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
|
||||
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
|
||||
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
|
||||
|
||||
const breadcrumb = <DynamicBreadcrumb dynamicMappings={new Map([[widget.id, widget.name]])} />;
|
||||
|
||||
const publish = async () => {
|
||||
// Publishing is immediate and public; a second in-flight call would create a
|
||||
// duplicate listing.
|
||||
if (createSubmission.isPending) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (!definition.data) throw new Error(t("publish.error"));
|
||||
if (!workshopScreenshotsSchema.safeParse(screenshots).success) throw new Error(t("publish.invalidScreenshot"));
|
||||
|
||||
let submissionId: string | null = null;
|
||||
const result = await publishWorkshopDefinition({
|
||||
inspectedDefinition: definition.data,
|
||||
refetchDefinition: async () => (await definition.refetch()).data,
|
||||
publish: async (content) => {
|
||||
const submission = await createSubmission.mutateAsync({
|
||||
input: { type: "customWidget", title, description, content },
|
||||
screenshots,
|
||||
});
|
||||
submissionId = submission.id;
|
||||
},
|
||||
});
|
||||
if (result === "unavailable") throw new Error(t("publish.error"));
|
||||
if (result === "changed") {
|
||||
setSourceUrlsReviewed(false);
|
||||
setError(t("publish.definitionChanged"));
|
||||
return;
|
||||
}
|
||||
if (!submissionId) throw new Error(t("publish.error"));
|
||||
setPublishedSubmissionId(submissionId);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("publish.error"));
|
||||
}
|
||||
};
|
||||
|
||||
if (publishedSubmissionId) {
|
||||
return (
|
||||
<ManagePageLayout title={t("publish.successTitle")} breadcrumb={breadcrumb}>
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={64} radius="xl" color="green" variant="light">
|
||||
<IconCheck size={32} stroke={2} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" ta="center" maw={520}>
|
||||
{t("publish.success")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" ta="center" maw={520}>
|
||||
{t("publish.manageDescription")}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button component={Link} href={listHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
|
||||
{t("publish.done")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(publishedSubmissionId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rightSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("publish.viewSubmission")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const blocked =
|
||||
!session.user ||
|
||||
createSubmission.isPending ||
|
||||
!definition.data ||
|
||||
definition.isError ||
|
||||
title.trim().length < 3 ||
|
||||
(privateSourceNames.length > 0 && !sourceUrlsReviewed);
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{t("publish.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{widget.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumb={breadcrumb}
|
||||
primaryAction={<WorkshopAccountButton session={session} />}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" radius="md" size="lg">
|
||||
<IconBuildingStore size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600}>{t("publish.introTitle")}</Text>
|
||||
<Text c="dimmed" size="sm" mt={2}>
|
||||
{t("publish.introDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{!session.user && <Alert color="blue">{t("publish.signInHint")}</Alert>}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600} size="sm">
|
||||
{t("publish.listingDetails")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" mt={2}>
|
||||
{t("publish.listingDetailsDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
label={t("publish.titleField")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.descriptionField")}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
minRows={4}
|
||||
autosize
|
||||
maxRows={8}
|
||||
/>
|
||||
<FileInput
|
||||
label={t("publish.screenshots")}
|
||||
description={t("publish.screenshotsDescription")}
|
||||
placeholder={t("publish.screenshotsPlaceholder")}
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{privateSourceNames.length > 0 && (
|
||||
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
|
||||
{t("publish.privateSourceWarning", {
|
||||
sources: privateSourceNames.join(", "),
|
||||
})}
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
checked={sourceUrlsReviewed}
|
||||
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
|
||||
label={t("publish.privateSourceConfirmation")}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
|
||||
<ManageStickyFooter
|
||||
secondary={
|
||||
<Text c="dimmed" size="xs" maw={420}>
|
||||
{t("publish.publicationNote")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Button component={Link} href={listHref} variant="default">
|
||||
{t("publish.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
loading={createSubmission.isPending || definition.isFetching}
|
||||
disabled={blocked}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
</Button>
|
||||
</ManageStickyFooter>
|
||||
</Stack>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { WorkshopPublishForm } from "./_workshop-publish-form";
|
||||
|
||||
interface WorkshopPublishPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkshopPublishPage(props: WorkshopPublishPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { id } = await props.params;
|
||||
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
|
||||
|
||||
return <WorkshopPublishForm widget={{ id, name: definition.name }} />;
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconFlag } from "@tabler/icons-react";
|
||||
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
|
||||
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop/schema";
|
||||
|
||||
import { useCustomWidgetImport } from "~/components/custom-widgets/use-custom-widget-import";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
|
||||
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
|
||||
import { useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||
|
||||
const browseHref = "/manage/custom-widgets/workshop";
|
||||
const installedHref = "/manage/custom-widgets";
|
||||
|
||||
export function WorkshopDetail({ id }: { id: string }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const session = useWorkshopSession();
|
||||
const [reportOpened, reportControls] = useDisclosure(false);
|
||||
|
||||
const detail = useWorkshopSubmissionQuery(session.client, id);
|
||||
const reportSummaries = useWorkshopReportSummariesQuery(session.client, detail.data?.reportCount ? id : "");
|
||||
|
||||
const content = detail.data?.content;
|
||||
const validation = useMemo(() => (content ? validateWorkshopWidget(content) : null), [content]);
|
||||
const widget = useMemo<HomarrCustomWidgetV2 | null>(
|
||||
() => (validation?.success && typeof validation.data !== "string" ? validation.data : null),
|
||||
[validation],
|
||||
);
|
||||
const compatible =
|
||||
detail.data?.type === "customWidget" && widget !== null && detail.data.widgetSchema === CUSTOM_WIDGET_SCHEMA;
|
||||
|
||||
const importer = useCustomWidgetImport({
|
||||
widget: compatible ? widget : null,
|
||||
// Straight back to the installed list: the edit form is heavy to load and the
|
||||
// widget is usable as-is once its sources are configured here.
|
||||
onImported: () => router.push(installedHref),
|
||||
});
|
||||
|
||||
const breadcrumb = (
|
||||
<DynamicBreadcrumb dynamicMappings={detail.data ? new Map([[id, detail.data.title]]) : undefined} />
|
||||
);
|
||||
|
||||
if (detail.isPending) {
|
||||
return (
|
||||
<ManagePageLayout title={<Skeleton h={36} w={280} />} breadcrumb={breadcrumb}>
|
||||
<Skeleton h={420} radius="md" />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.isError || !detail.data) {
|
||||
return (
|
||||
<ManagePageLayout title={t("title")} breadcrumb={breadcrumb}>
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("loadError")}>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">{detail.error instanceof Error ? detail.error.message : t("unavailable")}</Text>
|
||||
<Button
|
||||
component={Link}
|
||||
href={browseHref}
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconArrowLeft size={14} />}
|
||||
>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const submission = detail.data;
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{submission.title}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("author", {
|
||||
name: submission.authorName || t("communityMember"),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumb={breadcrumb}
|
||||
primaryAction={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button component={Link} href={browseHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(submission.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{submission.outdated && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("outdatedWarning")}
|
||||
</Alert>
|
||||
)}
|
||||
{submission.reportCount > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("reportWarning", { count: submission.reportCount })}
|
||||
</Alert>
|
||||
{reportSummaries.data?.map((summary) => (
|
||||
<Card key={summary.id} withBorder radius="md" p="sm">
|
||||
<Badge color="red" variant="light" mb="xs">
|
||||
{t(`reportCategory.${summary.category}`)}
|
||||
</Badge>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{summary.explanation}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
{reportSummaries.data?.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("reportVisibility")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{!compatible && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("installError")}>
|
||||
{t("installErrorDescription")}
|
||||
{validation && !validation.success ? ` ${validation.error}` : ""}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submission.description && <Text>{submission.description}</Text>}
|
||||
|
||||
<WorkshopScreenshots
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
title={submission.title}
|
||||
screenshots={submission.screenshots}
|
||||
/>
|
||||
|
||||
{compatible && importer.review && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text fw={600}>{t("installReviewTitle")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("securityNotice")}
|
||||
</Text>
|
||||
</Box>
|
||||
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={importer.setups}
|
||||
values={importer.values}
|
||||
onChange={importer.setValue}
|
||||
messages={importer.setupMessages}
|
||||
/>
|
||||
</ImportReviewContent>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="technical-details">
|
||||
<Accordion.Control>{t("technicalDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<WorkshopCodeViewer
|
||||
value={submission.content}
|
||||
language={submission.type === "customCss" ? "css" : "json"}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<ManageStickyFooter
|
||||
secondary={
|
||||
<>
|
||||
<WorkshopVoteControl
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
score={submission.score}
|
||||
canVote={session.user !== null}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => downloadSubmission(submission)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
{/* Wrapped: a tooltip on the disabled button itself would never fire. */}
|
||||
<Tooltip label={session.user ? t("report") : t("signInHint")}>
|
||||
<span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
leftSection={<IconFlag size={16} />}
|
||||
disabled={!session.user}
|
||||
onClick={reportControls.open}
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="md"
|
||||
loading={importer.pending || importer.succeeded}
|
||||
disabled={!compatible || !importer.ready || importer.succeeded}
|
||||
onClick={importer.importWidget}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
</ManageStickyFooter>
|
||||
</Stack>
|
||||
|
||||
<WorkshopReportModal
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
opened={reportOpened}
|
||||
onClose={reportControls.close}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadSubmission(submission: WorkshopSubmissionDetail) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([submission.content], {
|
||||
type: submission.type === "customCss" ? "text/css" : "application/json",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = workshopExportFilename(submission.title, submission.type);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { WorkshopDetail } from "./_workshop-detail";
|
||||
|
||||
interface WorkshopDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkshopDetailPage(props: WorkshopDetailPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { id } = await props.params;
|
||||
|
||||
return <WorkshopDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
|
||||
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
import { useWorkshopQuery } from "@homarr/workshop/backend";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
|
||||
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
|
||||
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
|
||||
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
|
||||
import { CustomWidgetTabs } from "../_custom-widget-tabs";
|
||||
|
||||
export const WORKSHOP_PAGE_SIZE = 12;
|
||||
|
||||
interface WorkshopBrowseProps {
|
||||
search: string | undefined;
|
||||
sort: "top" | "newest";
|
||||
page: number;
|
||||
}
|
||||
|
||||
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const query = useWorkshopQuery(session.client, {
|
||||
page,
|
||||
perPage: WORKSHOP_PAGE_SIZE,
|
||||
type: "customWidget",
|
||||
sort,
|
||||
search,
|
||||
});
|
||||
const totalPages = query.data?.totalPages ?? 1;
|
||||
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WorkshopAccountButton session={session} />
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
toolbar={
|
||||
<Stack gap="sm">
|
||||
<CustomWidgetTabs active="workshop" />
|
||||
<Group gap="sm" align="flex-end" wrap="nowrap">
|
||||
<SearchInput placeholder={`${t("search")}...`} ariaLabel={t("search")} defaultValue={search} flexExpand />
|
||||
<WorkshopSortSelect value={sort} />
|
||||
</Group>
|
||||
</Stack>
|
||||
}
|
||||
footer={totalPages > 1 ? <TablePagination total={totalPages} /> : undefined}
|
||||
>
|
||||
<WorkshopSubmissionGrid
|
||||
client={session.client}
|
||||
query={query}
|
||||
ariaLabel={t("listAriaLabel")}
|
||||
emptyState={
|
||||
search ? (
|
||||
<NoResults
|
||||
icon={IconBuildingStore}
|
||||
title={t("emptyFilteredTitle")}
|
||||
description={t("emptyFilteredDescription", { search })}
|
||||
action={{
|
||||
label: t("clearSearch"),
|
||||
href: "/manage/custom-widgets/workshop",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<NoResults icon={IconBuildingStore} title={t("empty")} description={t("emptyDescription")} />
|
||||
)
|
||||
}
|
||||
renderActions={(item) => (
|
||||
<>
|
||||
<WorkshopVoteControl
|
||||
client={session.client}
|
||||
submissionId={item.id}
|
||||
score={item.score}
|
||||
canVote={session.user !== null}
|
||||
size="sm"
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label={t("openCommunity")}>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(item.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label={t("openOnWorkshop", { title: item.title })}
|
||||
>
|
||||
<IconExternalLink size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button component={Link} href={`/manage/custom-widgets/workshop/${item.id}`} size="sm">
|
||||
{t("install")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string | null) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next === "newest") params.set("sort", next);
|
||||
else params.delete("sort");
|
||||
params.delete("page");
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={t("sort")}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
allowDeselect={false}
|
||||
w={160}
|
||||
data={[
|
||||
{ value: "top", label: t("sortTop") },
|
||||
{ value: "newest", label: t("sortNewest") },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
|
||||
import { WorkshopBrowse } from "./_workshop-browse";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
sort: z.enum(["top", "newest"]).catch("top"),
|
||||
page: z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u)
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.catch(1),
|
||||
});
|
||||
|
||||
interface WorkshopBrowsePageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
|
||||
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
|
||||
|
||||
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IconApi,
|
||||
IconBook2,
|
||||
IconBox,
|
||||
IconBuildingStore,
|
||||
IconBrandDiscord,
|
||||
IconBrandDocker,
|
||||
IconBrandGithub,
|
||||
@@ -102,12 +101,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
|
||||
@@ -1,46 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Alert, Button, Stack } from "@mantine/core";
|
||||
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
const { workshopWebUrl } = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
workshopWebUrl: env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
<Button
|
||||
component="a"
|
||||
href={workshopWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Alert icon={<IconInfoCircle size={18} />} title={t("manageSubmissionsTitle")} color="blue">
|
||||
{t("manageSubmissionsDescription")}
|
||||
</Alert>
|
||||
<WorkshopInstaller />
|
||||
</Stack>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
/** The Workshop now lives as a tab of the Custom Widgets page. */
|
||||
export default function WorkshopPage(): never {
|
||||
permanentRedirect("/manage/custom-widgets/workshop");
|
||||
}
|
||||
Reference in new issue
Block a user