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");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -17,7 +17,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
@@ -278,7 +277,18 @@ const ItemSelectModalContent = ({
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
applyCustomWidgetSourceSetup,
|
||||
getCustomWidgetSourceSetups,
|
||||
getImportReview,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
createCustomWidgetSourceSetupValues,
|
||||
CustomWidgetSourceSetupPanel,
|
||||
ImportReviewDialog,
|
||||
isCustomWidgetSourceSetupReady,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useCustomWidgetImport } from "./use-custom-widget-import";
|
||||
|
||||
interface CustomWidgetImportDialogProps {
|
||||
opened: boolean;
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
@@ -29,7 +15,12 @@ interface CustomWidgetImportDialogProps {
|
||||
stackId?: string;
|
||||
zIndex?: number;
|
||||
modalProps?: Pick<ModalProps, "size" | "styles">;
|
||||
labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
|
||||
labels?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
onClose(): void;
|
||||
onImported?(result: { id: string }): void;
|
||||
}
|
||||
@@ -46,62 +37,14 @@ export function CustomWidgetImportDialog({
|
||||
onImported,
|
||||
}: CustomWidgetImportDialogProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const setups = useMemo(() => getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget]);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
const configuredWidget = useMemo(() => {
|
||||
if (!widget) return null;
|
||||
return {
|
||||
...widget,
|
||||
sources: applyCustomWidgetSourceSetup(
|
||||
widget.sources,
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([sourceId, value]) => [
|
||||
sourceId,
|
||||
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [values, widget]);
|
||||
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
|
||||
const onSuccess = (result: { id: string }) => {
|
||||
showSuccessNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: legacyId ? t("notification.migrated") : t("notification.imported"),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
};
|
||||
const onError = (error: { message?: string }) => {
|
||||
showErrorNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
|
||||
});
|
||||
};
|
||||
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
|
||||
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
|
||||
const pending = importMutation.isPending || migrateMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
}, [opened, setups]);
|
||||
|
||||
const importWidget = () => {
|
||||
if (!configuredWidget) return;
|
||||
const secrets = Object.entries(values).flatMap(([sourceId, value]) =>
|
||||
Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
|
||||
else importMutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
const importer = useCustomWidgetImport({
|
||||
widget,
|
||||
legacyId,
|
||||
onImported: (result) => {
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ImportReviewDialog
|
||||
@@ -109,49 +52,26 @@ export function CustomWidgetImportDialog({
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
{...modalProps}
|
||||
review={review}
|
||||
pending={pending}
|
||||
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
review={importer.review}
|
||||
pending={importer.pending}
|
||||
confirmDisabled={!importer.ready}
|
||||
onClose={onClose}
|
||||
onConfirm={importWidget}
|
||||
onConfirm={importer.importWidget}
|
||||
messages={{
|
||||
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
|
||||
...importer.reviewMessages,
|
||||
description: legacyId
|
||||
? t("importReview.migrationDescription")
|
||||
: (labels?.description ?? t("importReview.description")),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
: (labels?.description ?? importer.reviewMessages.description),
|
||||
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
|
||||
cancel: labels?.cancel ?? t("importReview.cancel"),
|
||||
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.confirm ?? t("importReview.confirm")),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
>
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={setups}
|
||||
values={values}
|
||||
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
|
||||
messages={{
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("importReview.sourceSetup.description"),
|
||||
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
|
||||
baseUrl: t("workbench.sources.baseUrl"),
|
||||
networkScope: t("workbench.sources.networkScope"),
|
||||
authentication: t("workbench.sources.authentication"),
|
||||
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
|
||||
ready: t("importReview.sourceSetup.ready"),
|
||||
needsUrl: t("importReview.sourceSetup.needsUrl"),
|
||||
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
|
||||
credentialsOptional: t("importReview.sourceSetup.credentialsOptional"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
}}
|
||||
setups={importer.setups}
|
||||
values={importer.values}
|
||||
onChange={importer.setValue}
|
||||
messages={importer.setupMessages}
|
||||
/>
|
||||
</ImportReviewDialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
applyCustomWidgetSourceSetup,
|
||||
getCustomWidgetSourceSetups,
|
||||
getImportReview,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { createCustomWidgetSourceSetupValues, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
|
||||
import type {
|
||||
CustomWidgetSourceSetupMessages,
|
||||
CustomWidgetSourceSetupValue,
|
||||
ImportReviewContentMessages,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UseCustomWidgetImportOptions {
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
/** Set when replacing an existing v1 widget instead of creating a new one. */
|
||||
legacyId?: string;
|
||||
onImported?(result: { id: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single import pipeline behind every "add this custom widget" surface:
|
||||
* source setup, security review and the mutation itself. Keeping it in one hook
|
||||
* means the Workshop page and the file/clipboard dialog can never drift apart.
|
||||
*/
|
||||
export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCustomWidgetImportOptions) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
// Keyed on the sources themselves rather than the widget object: a background
|
||||
// refetch (window focus, or the invalidation a vote triggers) hands us a new but
|
||||
// identical object, and resetting on that would silently wipe credentials the
|
||||
// user is part-way through typing.
|
||||
const sourcesKey = JSON.stringify(widget?.sources ?? {});
|
||||
const setups = useMemo(
|
||||
() => getCustomWidgetSourceSetups(widget?.sources ?? {}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sourcesKey],
|
||||
);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
useEffect(() => setValues(createCustomWidgetSourceSetupValues(setups)), [setups]);
|
||||
|
||||
const configuredWidget = useMemo(() => {
|
||||
if (!widget) return null;
|
||||
return {
|
||||
...widget,
|
||||
sources: applyCustomWidgetSourceSetup(
|
||||
widget.sources,
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([sourceId, value]) => [
|
||||
sourceId,
|
||||
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [values, widget]);
|
||||
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
|
||||
|
||||
// A widget must only ever be imported once per visit: the mutation creates a new
|
||||
// record every call, so a second click would silently duplicate it.
|
||||
const [succeeded, setSucceeded] = useState(false);
|
||||
useEffect(() => setSucceeded(false), [widget]);
|
||||
|
||||
const onSuccess = (result: { id: string }) => {
|
||||
setSucceeded(true);
|
||||
showSuccessNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: legacyId ? t("notification.migrated") : t("notification.imported"),
|
||||
});
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
// Hand over to the caller before the server-side revalidation round-trip so
|
||||
// navigation is immediate rather than waiting on it.
|
||||
onImported?.(result);
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
};
|
||||
const onError = (error: { message?: string }) => {
|
||||
showErrorNotification({
|
||||
title: legacyId ? t("action.migrate") : t("action.import"),
|
||||
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
|
||||
});
|
||||
};
|
||||
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
|
||||
const pending = importMutation.isPending || migrateMutation.isPending;
|
||||
|
||||
const importWidget = () => {
|
||||
if (!configuredWidget || pending || succeeded) return;
|
||||
const secrets = Object.entries(values).flatMap(([sourceId, value]) =>
|
||||
Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
if (legacyId)
|
||||
migrateMutation.mutate({
|
||||
id: legacyId,
|
||||
widget: configuredWidget,
|
||||
secrets,
|
||||
});
|
||||
else importMutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
|
||||
const reviewMessages: ImportReviewContentMessages = {
|
||||
description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
};
|
||||
|
||||
const setupMessages: CustomWidgetSourceSetupMessages = {
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("importReview.sourceSetup.description"),
|
||||
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
|
||||
baseUrl: t("workbench.sources.baseUrl"),
|
||||
networkScope: t("workbench.sources.networkScope"),
|
||||
authentication: t("workbench.sources.authentication"),
|
||||
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
|
||||
ready: t("importReview.sourceSetup.ready"),
|
||||
needsUrl: t("importReview.sourceSetup.needsUrl"),
|
||||
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
|
||||
credentialsOptional: t("importReview.sourceSetup.credentialsOptional"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
};
|
||||
|
||||
return {
|
||||
review,
|
||||
setups,
|
||||
values,
|
||||
setValue: (sourceId: string, value: CustomWidgetSourceSetupValue) =>
|
||||
setValues((current) => ({ ...current, [sourceId]: value })),
|
||||
ready: isCustomWidgetSourceSetupReady(setups, values),
|
||||
pending,
|
||||
/** True once the import went through; callers keep the action locked while navigating away. */
|
||||
succeeded,
|
||||
importWidget,
|
||||
reviewMessages,
|
||||
setupMessages,
|
||||
};
|
||||
}
|
||||
@@ -19,16 +19,17 @@ export const ManageCollectionPage = ({
|
||||
...layoutProps
|
||||
}: ManageCollectionPageProps) => (
|
||||
<ManagePageLayout {...layoutProps}>
|
||||
{itemCount === 0 ? (
|
||||
emptyState
|
||||
) : (
|
||||
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
|
||||
{children}
|
||||
</Stack>
|
||||
)}
|
||||
{itemCount === 0 ? emptyState : <ManageCollectionList ariaLabel={ariaLabel}>{children}</ManageCollectionList>}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
/** The `ul` wrapper used by every manage list, so collections rendered outside `ManageCollectionPage` still match. */
|
||||
export const ManageCollectionList = ({ ariaLabel, children }: { ariaLabel: string; children: ReactNode }) => (
|
||||
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export interface ManageCollectionItemProps {
|
||||
leading?: ReactNode;
|
||||
title: ReactNode;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
|
||||
|
||||
export interface ManagePageLayoutProps {
|
||||
title: ReactNode;
|
||||
/** Overrides the default breadcrumb, e.g. to map a dynamic route segment onto a readable name. */
|
||||
breadcrumb?: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
@@ -18,6 +20,7 @@ export interface ManagePageLayoutProps {
|
||||
|
||||
export const ManagePageLayout = ({
|
||||
title,
|
||||
breadcrumb,
|
||||
primaryAction,
|
||||
toolbar,
|
||||
footer,
|
||||
@@ -29,7 +32,7 @@ export const ManagePageLayout = ({
|
||||
|
||||
return (
|
||||
<ManageContainer size={size}>
|
||||
<DynamicBreadcrumb />
|
||||
{breadcrumb ?? <DynamicBreadcrumb />}
|
||||
<Stack pb={floatingPrimaryAction ? { base: MANAGE_FLOATING_ACTION_BOTTOM_OFFSET, md: 0 } : undefined}>
|
||||
<Group justify="space-between" align="center">
|
||||
{titleNode}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Group, Paper } from "@mantine/core";
|
||||
|
||||
export interface ManageStickyFooterProps {
|
||||
/** Secondary controls, pinned to the start of the bar. */
|
||||
secondary?: ReactNode;
|
||||
/** The primary action(s) of the page, pinned to the end of the bar. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bar that stays visible at the bottom of the viewport while the page scrolls,
|
||||
* so the primary action of a long page is always one click away.
|
||||
*/
|
||||
export const ManageStickyFooter = ({ secondary, children }: ManageStickyFooterProps) => (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="md"
|
||||
p="sm"
|
||||
pos="sticky"
|
||||
bottom="var(--mantine-spacing-md)"
|
||||
bg="var(--mantine-color-body)"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<Group justify="space-between" align="center" gap="sm" wrap="wrap">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{secondary}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" ml="auto">
|
||||
{children}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("DynamicBreadcrumb", () => {
|
||||
it("falls back to the raw segment when a path segment has no translation", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
|
||||
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
|
||||
expect(source).toContain("t.has(labelKey)");
|
||||
expect(source).toContain("mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart)");
|
||||
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
|
||||
});
|
||||
});
|
||||
@@ -65,24 +65,19 @@ export const DynamicBreadcrumb = ({
|
||||
const isNonInteractable =
|
||||
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
|
||||
|
||||
if (mappedValue) {
|
||||
if (isNonInteractable) {
|
||||
return <Text key={href}>{mappedValue}</Text>;
|
||||
}
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{mappedValue}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
// Dynamic segments (ids) have no translation and may not be mapped yet while
|
||||
// their page is still loading. Fall back to the raw segment instead of
|
||||
// raising MISSING_MESSAGE.
|
||||
const labelKey = `${translationKey}.label` as TranslationKeys;
|
||||
const label = mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart);
|
||||
|
||||
if (isNonInteractable) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
return <Text key={href}>{label}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
{label}
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,748 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBigDown,
|
||||
IconArrowBigUp,
|
||||
IconArrowRight,
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconExternalLink,
|
||||
IconFlag,
|
||||
IconMessageCircle,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
|
||||
import {
|
||||
useWorkshopCssImportMutation,
|
||||
useWorkshopQuery,
|
||||
useWorkshopReportSummariesQuery,
|
||||
useWorkshopReportMutation,
|
||||
useWorkshopSubmissionQuery,
|
||||
useWorkshopVoteMutation,
|
||||
useWorkshopWidgetImportMutation,
|
||||
} from "@homarr/workshop/backend";
|
||||
import type {
|
||||
WorkshopReport,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionType,
|
||||
WorkshopUser,
|
||||
} from "@homarr/workshop/schema";
|
||||
import {
|
||||
validateWorkshopContent,
|
||||
validateWorkshopWidget,
|
||||
WORKSHOP_CSS_SCHEMA,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop/schema";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
|
||||
|
||||
function downloadWorkshopSubmission(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);
|
||||
}
|
||||
|
||||
interface WorkshopBrowserProps {
|
||||
type?: WorkshopSubmissionType;
|
||||
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
|
||||
onUseCss?(css: string): void;
|
||||
modalStack?: {
|
||||
modalProps: Pick<ModalProps, "size" | "styles">;
|
||||
details: WorkshopModalStep;
|
||||
report: WorkshopModalStep;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkshopModalStep {
|
||||
opened: boolean;
|
||||
stackId?: string;
|
||||
open(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const detailsModalStyles = {
|
||||
content: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
height: "min(85dvh, 800px)",
|
||||
},
|
||||
body: {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column" as const,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: WorkshopBrowserProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const currentLocale = useCurrentLocale();
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(currentLocale, { dateStyle: "medium" }), [currentLocale]);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [internalReportOpened, reportControls] = useDisclosure(false);
|
||||
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [reportExplanation, setReportExplanation] = useState("");
|
||||
const [loginPending, setLoginPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const workshopWebUrl = useMemo(getWorkshopWebUrl, []);
|
||||
const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
|
||||
const reportOpened = modalStack?.report.opened ?? internalReportOpened;
|
||||
const openDetails = (id: string) => {
|
||||
setSelectedId(id);
|
||||
modalStack?.details.open();
|
||||
};
|
||||
const closeDetails = () => {
|
||||
modalStack?.details.close();
|
||||
setSelectedId(null);
|
||||
};
|
||||
const openReport = modalStack?.report.open ?? reportControls.open;
|
||||
const closeReport = modalStack?.report.close ?? reportControls.close;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
|
||||
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
|
||||
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
|
||||
const reportSummaries = useWorkshopReportSummariesQuery(client, selectedId ?? "");
|
||||
const detailValidation = useMemo(() => {
|
||||
if (!detail.data) return null;
|
||||
return type === "customCss"
|
||||
? validateWorkshopContent("customCss", detail.data.content)
|
||||
: validateWorkshopWidget(detail.data.content);
|
||||
}, [detail.data, type]);
|
||||
const expectedSchema = type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA;
|
||||
const detailCompatible =
|
||||
detail.data?.type === type && detailValidation?.success === true && detail.data.widgetSchema === expectedSchema;
|
||||
const signIn = () => {
|
||||
setLoginPending(true);
|
||||
setLoginError(null);
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: unknown) => {
|
||||
const message = cause instanceof Error ? cause.message : t("signInError");
|
||||
setLoginError(message);
|
||||
showErrorNotification({ title: t("signIn"), message });
|
||||
})
|
||||
.finally(() => setLoginPending(false));
|
||||
};
|
||||
const install = useWorkshopWidgetImportMutation(onInstall);
|
||||
const useCss = useWorkshopCssImportMutation(onUseCss, () => setSelectedId(null));
|
||||
const vote = useWorkshopVoteMutation(client);
|
||||
const report = useWorkshopReportMutation(client);
|
||||
const submitReport = () =>
|
||||
report.mutate(
|
||||
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
|
||||
{
|
||||
onSuccess: () => {
|
||||
closeReport();
|
||||
setReportExplanation("");
|
||||
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group align="flex-start">
|
||||
<Box
|
||||
p="sm"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
|
||||
>
|
||||
<IconBuildingStore size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
<Text c="dimmed" size="sm" maw={640}>
|
||||
{type === "customCss" ? t("descriptionCss") : t("description")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
component="a"
|
||||
href={workshopWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconExternalLink size={14} />}
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
{user ? (
|
||||
<>
|
||||
<Badge variant="light">{user.name}</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
client.signOut();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="xs" loading={loginPending} onClick={signIn}>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
{type === "customCss" ? t("securityNoticeCss") : t("securityNotice")}
|
||||
</Alert>
|
||||
{loginError && <Alert color="red">{loginError}</Alert>}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label={type === "customCss" ? t("searchCss") : t("search")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("sort")}
|
||||
value={sort}
|
||||
onChange={(value) => {
|
||||
setSort(value === "newest" ? "newest" : "top");
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ value: "top", label: t("sortTop") },
|
||||
{ value: "newest", label: t("sortNewest") },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{list.isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : list.isError ? (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">{t("unavailable")}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={list.isFetching}
|
||||
onClick={() => void list.refetch()}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : list.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{list.data.items.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p={0}
|
||||
h={440}
|
||||
w="100%"
|
||||
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
|
||||
>
|
||||
{item.screenshots[0] ? (
|
||||
<Card.Section>
|
||||
<Image
|
||||
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
|
||||
h={190}
|
||||
fit="cover"
|
||||
alt={t("screenshotAlt", { title: item.title, count: 1 })}
|
||||
/>
|
||||
</Card.Section>
|
||||
) : (
|
||||
<Card.Section>
|
||||
<Box
|
||||
h={190}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ display: "grid", placeItems: "center" }}
|
||||
>
|
||||
<Code fz="md">{item.type === "customCss" ? "CSS" : "JSX"}</Code>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
)}
|
||||
<Stack gap="sm" p="md" style={{ flex: 1 }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text fw={700} size="lg" lineClamp={1} miw={0}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Group
|
||||
gap={7}
|
||||
wrap="nowrap"
|
||||
px="xs"
|
||||
py={6}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)", flexShrink: 0 }}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
{t("voteSummary", { upvotes: item.upvotes, downvotes: item.downvotes })}
|
||||
</VisuallyHidden>
|
||||
<IconArrowBigUp aria-hidden size={16} color="var(--mantine-primary-color-filled)" />
|
||||
<Text aria-hidden size="sm" fw={700} lh={1}>
|
||||
{item.score}
|
||||
</Text>
|
||||
<IconArrowBigDown aria-hidden size={16} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("author", { name: item.authorName || t("communityMember") })} · v{item.revision} ·{" "}
|
||||
{dateFormatter.format(new Date(item.updated))}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{item.outdated && <Badge color="yellow">{t("outdated")}</Badge>}
|
||||
{item.reportCount > 0 && <Badge color="red">{t("reportCount", { count: item.reportCount })}</Badge>}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" lineClamp={3}>
|
||||
{item.description || t("noDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card.Section withBorder p="sm" bg="var(--mantine-color-default-hover)">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} c="dimmed" wrap="nowrap">
|
||||
<IconMessageCircle size={16} />
|
||||
<Text size="xs">{t("comments", { count: item.commentCount })}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={15} />}
|
||||
onClick={() => openDetails(item.id)}
|
||||
>
|
||||
{t("viewDetails")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{type === "customCss" ? t("emptyCss") : t("empty")}
|
||||
</Text>
|
||||
)}
|
||||
{(list.data?.totalPages ?? 0) > 1 && (
|
||||
<Pagination total={list.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
{...modalStack?.modalProps}
|
||||
opened={detailsOpened}
|
||||
onClose={closeDetails}
|
||||
stackId={modalStack?.details.stackId}
|
||||
title={type === "customCss" ? t("inspectCss") : t("install")}
|
||||
size={modalStack?.modalProps.size ?? "xl"}
|
||||
styles={detailsModalStyles}
|
||||
>
|
||||
{detail.isPending ? (
|
||||
<Box p="lg">
|
||||
<Skeleton h={400} />
|
||||
</Box>
|
||||
) : detail.isError || !detail.data ? (
|
||||
<Box p="lg">
|
||||
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Box>
|
||||
<Title order={3}>{detail.data.title}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("author", { name: detail.data.authorName || t("communityMember") })}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component="a"
|
||||
href={getWorkshopWebUrl(detail.data.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
rightSection={<IconExternalLink size={14} />}
|
||||
>
|
||||
{t("openCommunity")}
|
||||
</Button>
|
||||
</Group>
|
||||
{detail.data.description && <Text c="dimmed">{detail.data.description}</Text>}
|
||||
{detail.data.outdated && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("outdatedWarning")}
|
||||
</Alert>
|
||||
)}
|
||||
{detail.data.reportCount > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("reportWarning", { count: detail.data.reportCount })}
|
||||
</Alert>
|
||||
{reportSummaries.data?.map((summary) => (
|
||||
<Card key={summary.id} withBorder 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>
|
||||
)}
|
||||
{detail.data.screenshots.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{detail.data.screenshots.map((file, index) => (
|
||||
<Image
|
||||
key={file}
|
||||
src={client.fileUrl(detail.data.id, file, "960x640")}
|
||||
radius="md"
|
||||
alt={t("screenshotAlt", { title: detail.data.title, count: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{detailValidation && !detailValidation.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")} {detailValidation.error}
|
||||
</Alert>
|
||||
)}
|
||||
{detailValidation?.success && !detailCompatible && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")}
|
||||
</Alert>
|
||||
)}
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="technical-details">
|
||||
<Accordion.Control>{t("technicalDetails")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
{type === "customWidget" &&
|
||||
detailValidation?.success &&
|
||||
typeof detailValidation.data !== "string" && (
|
||||
<CapabilitySummary widget={detailValidation.data} />
|
||||
)}
|
||||
<WorkshopCodeViewer
|
||||
value={detail.data.content}
|
||||
language={type === "customCss" ? "css" : "json"}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{!user && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("signInHint")}
|
||||
</Text>
|
||||
)}
|
||||
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
|
||||
{install.error && <Alert color="red">{install.error.message}</Alert>}
|
||||
{useCss.error && <Alert color="red">{useCss.error.message}</Alert>}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box
|
||||
px="lg"
|
||||
py="md"
|
||||
bg="var(--mantine-color-body)"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-default-border)", flexShrink: 0 }}
|
||||
>
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => downloadWorkshopSubmission(detail.data)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
p={4}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="lg"
|
||||
loading={vote.isPending && vote.variables?.value === 1}
|
||||
disabled={!user || vote.isPending}
|
||||
aria-label={t("upvote", { count: detail.data.upvotes })}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
|
||||
>
|
||||
<IconArrowBigUp size={20} />
|
||||
</ActionIcon>
|
||||
<Text miw={24} ta="center" fw={700} aria-label={t("score", { count: detail.data.score })}>
|
||||
{detail.data.score}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
loading={vote.isPending && vote.variables?.value === -1}
|
||||
disabled={!user || vote.isPending}
|
||||
aria-label={t("downvote", { count: detail.data.downvotes })}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
|
||||
>
|
||||
<IconArrowBigDown size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
leftSection={<IconFlag size={16} />}
|
||||
disabled={!user}
|
||||
onClick={openReport}
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
</Group>
|
||||
{onInstall && (
|
||||
<Button
|
||||
size="sm"
|
||||
loading={install.isPending}
|
||||
disabled={!detailCompatible}
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() =>
|
||||
detailValidation?.success &&
|
||||
typeof detailValidation.data !== "string" &&
|
||||
install.mutate(detailValidation.data)
|
||||
}
|
||||
>
|
||||
{t("continueInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{onUseCss && (
|
||||
<Button
|
||||
size="sm"
|
||||
loading={useCss.isPending}
|
||||
disabled={!detailCompatible}
|
||||
onClick={() =>
|
||||
detailValidation?.success &&
|
||||
typeof detailValidation.data === "string" &&
|
||||
useCss.mutate(detailValidation.data)
|
||||
}
|
||||
>
|
||||
{t("useCss")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
{...modalStack?.modalProps}
|
||||
opened={reportOpened}
|
||||
onClose={closeReport}
|
||||
stackId={modalStack?.report.stackId}
|
||||
title={t("reportTitle")}
|
||||
>
|
||||
<Stack>
|
||||
<Select
|
||||
label={t("reportReason")}
|
||||
value={reportCategory}
|
||||
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
|
||||
data={(["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`reportCategory.${value}`),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("reportExplanation")}
|
||||
minRows={4}
|
||||
value={reportExplanation}
|
||||
onChange={(event) => setReportExplanation(event.currentTarget.value)}
|
||||
/>
|
||||
{report.error && <Alert color="red">{report.error.message}</Alert>}
|
||||
<Button loading={report.isPending} disabled={reportExplanation.trim().length < 3} onClick={submitReport}>
|
||||
{t("reportSend")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopCodeViewer({ value, language }: { value: string; language: "json" | "css" }) {
|
||||
const id = useId();
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const workshopT = useScopedI18n("workshop");
|
||||
const messages: CustomWidgetEditorMessages = {
|
||||
languageJsx: t("language.jsx"),
|
||||
languageJson: t("language.json"),
|
||||
undo: t("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: t("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: t("reference.schema"),
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
|
||||
};
|
||||
let displayValue = value;
|
||||
if (language === "json") {
|
||||
try {
|
||||
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
|
||||
} catch {}
|
||||
}
|
||||
return (
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`workshop-source-${id}`}
|
||||
label={workshopT("sourceCode")}
|
||||
language={language}
|
||||
value={displayValue}
|
||||
messages={messages}
|
||||
height="360px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Card withBorder p="sm">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("sources")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{Object.entries(widget.sources).map(([sourceId, source]) => (
|
||||
<Box key={sourceId}>
|
||||
<Text size="sm" fw={500}>
|
||||
{source.name}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light">
|
||||
{new URL(source.baseUrl).origin}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{source.networkScope}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{typeof source.auth === "string" ? source.auth : source.auth.type}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card withBorder p="sm">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("requests")}
|
||||
</Text>
|
||||
{Object.keys(widget.requests).length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("noRequests")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{Object.entries(widget.requests).map(([requestId, request]) => (
|
||||
<Group key={requestId} justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{requestId}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs">{request.kind}</Badge>
|
||||
<Badge size="xs" variant="light">
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{request.permission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
/** Read-only source view of a Workshop submission, used for both widget JSON and Custom CSS. */
|
||||
export function WorkshopCodeViewer({
|
||||
value,
|
||||
language,
|
||||
height = "360px",
|
||||
}: {
|
||||
value: string;
|
||||
language: "json" | "css";
|
||||
height?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const t = useScopedI18n("customWidget.editor");
|
||||
const workshopT = useScopedI18n("workshop");
|
||||
const messages: CustomWidgetEditorMessages = {
|
||||
languageJsx: t("language.jsx"),
|
||||
languageJson: t("language.json"),
|
||||
undo: t("action.undo"),
|
||||
redo: t("action.redo"),
|
||||
components: t("action.components"),
|
||||
componentSearch: t("componentReference.search"),
|
||||
componentEmpty: t("componentReference.empty"),
|
||||
componentCount: (count) => t("componentReference.count", { count }),
|
||||
insertStarter: t("action.insertStarter"),
|
||||
format: t("action.format"),
|
||||
copy: t("action.copy"),
|
||||
copied: t("action.copied"),
|
||||
schema: t("action.schema"),
|
||||
schemaTab: t("reference.schema"),
|
||||
minimalTab: t("reference.minimal"),
|
||||
fullTab: t("reference.full"),
|
||||
errors: (count) => t("status.errors", { count }),
|
||||
warnings: (count) => t("status.warnings", { count }),
|
||||
ready: t("status.ready"),
|
||||
position: (cursor) => t("status.position", cursor),
|
||||
characters: (count, limit) =>
|
||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||
diagnosticsTitle: t("diagnostics.title"),
|
||||
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
|
||||
};
|
||||
|
||||
let displayValue = value;
|
||||
if (language === "json") {
|
||||
try {
|
||||
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`workshop-source-${id}`}
|
||||
label={workshopT("sourceCode")}
|
||||
language={language}
|
||||
value={displayValue}
|
||||
messages={messages}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Modal, useModalsStack } from "@mantine/core";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
import { modalSizeSelect, useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useWorkshopQuery } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
|
||||
import { validateWorkshopContent } from "@homarr/workshop/schema";
|
||||
|
||||
const cssInstallModalProps = {
|
||||
size: "90%",
|
||||
styles: {
|
||||
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
|
||||
body: { flex: 1, overflowY: "auto" },
|
||||
},
|
||||
} satisfies Pick<ModalProps, "size" | "styles">;
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { getWorkshopWebUrl } from "./workshop-client";
|
||||
import { useWorkshopSession } from "./workshop-session";
|
||||
import { WorkshopSubmissionGrid } from "./workshop-submission-grid";
|
||||
import { WorkshopVoteControl } from "./workshop-vote-control";
|
||||
|
||||
/**
|
||||
* Picks a community CSS theme in a single surface: browse, confirm, done.
|
||||
* The imported rules land in the editor below and are only persisted when the
|
||||
* board settings themselves are saved, so there is nothing to preview twice.
|
||||
*/
|
||||
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
|
||||
const stack = useModalsStack(["workshop", "details", "report"]);
|
||||
const detailsModal = stack.register("details");
|
||||
const reportModal = stack.register("report");
|
||||
const t = useScopedI18n("workshop");
|
||||
const [opened, controls] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => stack.open("workshop")}
|
||||
>
|
||||
Import from Workshop
|
||||
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={controls.open}>
|
||||
{t("importCss")}
|
||||
</Button>
|
||||
<Modal.Stack>
|
||||
<Modal {...stack.register("workshop")} {...cssInstallModalProps} title="Import Custom CSS">
|
||||
<WorkshopBrowser
|
||||
type="customCss"
|
||||
modalStack={{
|
||||
modalProps: cssInstallModalProps,
|
||||
details: {
|
||||
opened: detailsModal.opened,
|
||||
stackId: detailsModal.stackId,
|
||||
open: () => stack.open("details"),
|
||||
close: () => stack.close("details"),
|
||||
},
|
||||
report: {
|
||||
opened: reportModal.opened,
|
||||
stackId: reportModal.stackId,
|
||||
open: () => stack.open("report"),
|
||||
close: () => stack.close("report"),
|
||||
},
|
||||
}}
|
||||
onUseCss={(css) => {
|
||||
onImport(css);
|
||||
stack.closeAll();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</Modal.Stack>
|
||||
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
|
||||
<WorkshopCssPicker
|
||||
onImport={(css) => {
|
||||
onImport(css);
|
||||
controls.close();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopCssPicker({ onImport }: { onImport(css: string): void }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const session = useWorkshopSession();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const query = useWorkshopQuery(session.client, {
|
||||
page,
|
||||
perPage: 8,
|
||||
type: "customCss",
|
||||
sort: "top",
|
||||
search: debouncedSearch,
|
||||
});
|
||||
const totalPages = query.data?.totalPages ?? 1;
|
||||
|
||||
const useTheme = async (item: WorkshopSubmissionSummary) => {
|
||||
setPendingId(item.id);
|
||||
try {
|
||||
const submission = await session.client.get(item.id);
|
||||
const validation = validateWorkshopContent("customCss", submission.content);
|
||||
if (!validation.success) throw new Error(validation.error);
|
||||
const css = validation.data;
|
||||
if (typeof css !== "string") throw new Error(t("loadError"));
|
||||
openConfirmModal({
|
||||
title: t("importCss"),
|
||||
children: t("importCssConfirm", { title: item.title }),
|
||||
onConfirm: () => onImport(css),
|
||||
});
|
||||
} catch (cause) {
|
||||
showErrorNotification({
|
||||
title: t("importCss"),
|
||||
message: cause instanceof Error ? cause.message : t("loadError"),
|
||||
});
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("securityNoticeCss")}
|
||||
</Text>
|
||||
<TextInput
|
||||
aria-label={t("searchCss")}
|
||||
placeholder={`${t("searchCss")}...`}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={query.isFetching ? <Loader size={16} /> : undefined}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<WorkshopSubmissionGrid
|
||||
client={session.client}
|
||||
query={query}
|
||||
ariaLabel={t("listAriaLabelCss")}
|
||||
cols={{ base: 1, sm: 2 }}
|
||||
emptyState={<NoResults icon={IconSearch} title={t("emptyCss")} />}
|
||||
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 size="sm" loading={pendingId === item.id} onClick={() => void useTheme(item)}>
|
||||
{t("useCss")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{totalPages > 1 && <Pagination total={totalPages} value={page} onChange={setPage} mx="auto" />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button, Modal, useModalsStack } from "@mantine/core";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
interface WorkshopInstallButtonProps {
|
||||
children?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const installFlowModalProps = {
|
||||
size: "90%",
|
||||
styles: {
|
||||
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
|
||||
body: { flex: 1, overflowY: "auto" },
|
||||
},
|
||||
} satisfies Pick<ModalProps, "size" | "styles">;
|
||||
|
||||
const installReviewModalProps = {
|
||||
size: "90%",
|
||||
styles: {
|
||||
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
|
||||
body: {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
} satisfies Pick<ModalProps, "size" | "styles">;
|
||||
|
||||
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const stack = useModalsStack(["workshop", "details", "report", "review"]);
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const detailsModal = stack.register("details");
|
||||
const reportModal = stack.register("report");
|
||||
|
||||
const returnToDetails = () => {
|
||||
stack.close("review");
|
||||
setPendingWidget(null);
|
||||
stack.open("details");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
onClick={() => stack.open("workshop")}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
{children ?? t("importFromWorkshop")}
|
||||
</Button>
|
||||
<Modal.Stack>
|
||||
<Modal {...stack.register("workshop")} {...installFlowModalProps} title={t("installDialog")}>
|
||||
<WorkshopBrowser
|
||||
modalStack={{
|
||||
modalProps: installFlowModalProps,
|
||||
details: {
|
||||
opened: detailsModal.opened,
|
||||
stackId: detailsModal.stackId,
|
||||
open: () => stack.open("details"),
|
||||
close: () => stack.close("details"),
|
||||
},
|
||||
report: {
|
||||
opened: reportModal.opened,
|
||||
stackId: reportModal.stackId,
|
||||
open: () => stack.open("report"),
|
||||
close: () => stack.close("report"),
|
||||
},
|
||||
}}
|
||||
onInstall={async (widget) => {
|
||||
setPendingWidget(widget);
|
||||
stack.close("details");
|
||||
stack.open("review");
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<CustomWidgetImportDialog
|
||||
{...stack.register("review")}
|
||||
widget={pendingWidget}
|
||||
modalProps={installReviewModalProps}
|
||||
labels={{
|
||||
title: t("confirmInstallTitle"),
|
||||
description: t("confirmInstallDescription"),
|
||||
cancel: t("backToWidget"),
|
||||
confirm: t("install"),
|
||||
}}
|
||||
onClose={returnToDetails}
|
||||
onImported={() => {
|
||||
stack.closeAll();
|
||||
setPendingWidget(null);
|
||||
}}
|
||||
/>
|
||||
</Modal.Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,13 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
|
||||
|
||||
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
|
||||
|
||||
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
|
||||
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
|
||||
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
|
||||
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
|
||||
|
||||
describe("Workshop widget install flow", () => {
|
||||
afterEach(() => {
|
||||
document.head.innerHTML = "";
|
||||
@@ -32,32 +39,124 @@ describe("Workshop widget install flow", () => {
|
||||
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
|
||||
});
|
||||
|
||||
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
|
||||
const installButton = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
|
||||
it("installs from a page with a sticky footer instead of stacked modals", () => {
|
||||
const detail = read(detailPage);
|
||||
|
||||
expect(installButton).toContain('useModalsStack(["workshop", "details", "report", "review"])');
|
||||
expect(installButton).toContain("<Modal.Stack>");
|
||||
expect(installButton).toContain('height: "min(85dvh, 900px)"');
|
||||
expect(installButton).not.toContain("getDefaultZIndex");
|
||||
expect(browser).toContain("ReadOnlyCustomWidgetCode");
|
||||
expect(browser).not.toContain("{item.widgetSchema}");
|
||||
expect(detail).toContain("ManageStickyFooter");
|
||||
expect(detail).toContain("useCustomWidgetImport");
|
||||
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
|
||||
expect(detail).not.toContain("Modal.Stack");
|
||||
expect(detail).not.toContain("useModalsStack");
|
||||
expect(detail).not.toContain("CustomWidgetImportDialog");
|
||||
});
|
||||
|
||||
it("uses a two-step modal stack for Custom CSS", () => {
|
||||
const cssButton = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
|
||||
it("browses the Workshop as a card grid with per-item Workshop links", () => {
|
||||
const browse = read(browsePage);
|
||||
|
||||
expect(cssButton).toContain('useModalsStack(["workshop", "details", "report"])');
|
||||
expect(cssButton).toContain("<Modal.Stack>");
|
||||
expect(cssButton).toContain('height: "min(85dvh, 900px)"');
|
||||
expect(browser).not.toContain("cssAwaitingConfirmation");
|
||||
expect(browser).toContain("useWorkshopReportSummariesQuery");
|
||||
expect(browse).toContain("WorkshopSubmissionGrid");
|
||||
expect(browse).toContain("SearchInput");
|
||||
expect(browse).toContain("TablePagination");
|
||||
expect(browse).toContain("CustomWidgetTabs");
|
||||
expect(browse).toContain("getWorkshopWebUrl(item.id)");
|
||||
expect(browse).toContain("WorkshopVoteControl");
|
||||
expect(browse).not.toContain("Modal");
|
||||
});
|
||||
|
||||
it("renders the grid with real cards and screenshot previews", () => {
|
||||
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
|
||||
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
|
||||
|
||||
expect(grid).toContain("SimpleGrid");
|
||||
expect(card).toContain("Card.Section");
|
||||
expect(card).toContain("PREVIEW_HEIGHT");
|
||||
});
|
||||
|
||||
it("shows the viewer's own vote as a filled control", () => {
|
||||
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
|
||||
|
||||
expect(vote).toContain("useWorkshopUserVotesQuery");
|
||||
expect(vote).toContain("IconArrowBigUpFilled");
|
||||
expect(vote).toContain("IconArrowBigDownFilled");
|
||||
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
|
||||
expect(vote).toContain("aria-pressed={upvoted}");
|
||||
});
|
||||
|
||||
it("previews screenshots large with a zoom lightbox", () => {
|
||||
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
|
||||
const detail = read(detailPage);
|
||||
|
||||
expect(screenshots).toContain("1200x800");
|
||||
expect(screenshots).toContain("zoomScreenshot");
|
||||
expect(detail).toContain("WorkshopScreenshots");
|
||||
});
|
||||
|
||||
it("publishes from a page with the same sticky footer", () => {
|
||||
const publish = read(publishPage);
|
||||
|
||||
expect(publish).toContain("ManageStickyFooter");
|
||||
expect(publish).not.toContain("useModalsStack");
|
||||
expect(publish).not.toMatch(/<Modal\b/u);
|
||||
});
|
||||
|
||||
it("imports Custom CSS from a single modal", () => {
|
||||
const css = read(cssButton);
|
||||
|
||||
expect(css).toContain("WorkshopSubmissionGrid");
|
||||
expect(css).toContain("useConfirmModal");
|
||||
expect(css).not.toContain("useModalsStack");
|
||||
expect(css).not.toContain("Modal.Stack");
|
||||
});
|
||||
|
||||
it("cannot import the same widget twice and returns to the installed list", () => {
|
||||
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const detail = read(detailPage);
|
||||
|
||||
// The mutation creates a new record per call, so a second click must be impossible.
|
||||
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
|
||||
expect(importer).toContain("setSucceeded(true)");
|
||||
expect(detail).toContain("router.push(installedHref)");
|
||||
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
|
||||
// The heavy edit form is no longer the post-install destination.
|
||||
expect(detail).not.toContain("/manage/custom-widgets/edit/");
|
||||
});
|
||||
|
||||
it("keeps typed credentials across a background refetch of the submission", () => {
|
||||
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
|
||||
const detail = read(detailPage);
|
||||
|
||||
// Voting invalidates the detail query. If the setup reset keyed off the widget
|
||||
// object identity, the refetch would silently clear part-typed API keys.
|
||||
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
|
||||
expect(importer).toContain("[sourcesKey]");
|
||||
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
|
||||
expect(detail).toContain("const content = detail.data?.content;");
|
||||
expect(detail).toContain("[content]");
|
||||
});
|
||||
|
||||
it("keeps the sign-in hint reachable when voting is unavailable", () => {
|
||||
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
|
||||
|
||||
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
|
||||
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
|
||||
});
|
||||
|
||||
it("cannot publish the same widget twice", () => {
|
||||
const publish = read(publishPage);
|
||||
|
||||
expect(publish).toContain("if (createSubmission.isPending) return;");
|
||||
expect(publish).toContain("createSubmission.isPending ||");
|
||||
});
|
||||
|
||||
it("no longer ships the stacked-modal install components", () => {
|
||||
for (const removed of [
|
||||
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
|
||||
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
|
||||
]) {
|
||||
expect(() => read(removed)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopInstaller() {
|
||||
const router = useRouter();
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, reviewControls] = useDisclosure(false);
|
||||
|
||||
const install = async (widget: HomarrCustomWidgetV2) => {
|
||||
setPendingWidget(widget);
|
||||
reviewControls.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkshopBrowser onInstall={install} />
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingWidget}
|
||||
onClose={() => {
|
||||
reviewControls.close();
|
||||
setPendingWidget(null);
|
||||
}}
|
||||
onImported={(result) => router.push(`/manage/custom-widgets/edit/${result.id}`)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconBuildingStore, IconCheck, IconExternalLink, IconPhoto, IconShieldCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
|
||||
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
|
||||
import {
|
||||
getPrivateWorkshopSourceNames,
|
||||
publishWorkshopDefinition,
|
||||
serializeWorkshopDefinition,
|
||||
} from "./workshop-publish-definition";
|
||||
|
||||
export function WorkshopPublishModal({
|
||||
opened,
|
||||
onClose,
|
||||
widget,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
widget: { id: string; name: string };
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const createSubmission = useWorkshopCreateMutation(client);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
|
||||
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
|
||||
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
|
||||
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
|
||||
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setTitle(widget.name);
|
||||
setDescription("");
|
||||
setScreenshots([]);
|
||||
setError(null);
|
||||
setPublishedSubmissionId(null);
|
||||
setSourceUrlsReviewed(false);
|
||||
}, [opened, widget.name]);
|
||||
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
|
||||
|
||||
const publish = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
if (!definition.data) throw new Error(t("publish.error"));
|
||||
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
|
||||
throw new Error(t("publish.invalidScreenshot"));
|
||||
}
|
||||
const inspectedDefinition = definition.data;
|
||||
let submissionId: string | null = null;
|
||||
const result = await publishWorkshopDefinition({
|
||||
inspectedDefinition,
|
||||
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"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg" radius="lg">
|
||||
<Stack gap="lg">
|
||||
{publishedSubmissionId ? (
|
||||
<Stack align="center" gap="lg" py="md">
|
||||
<ThemeIcon size={64} radius="xl" color="green" variant="light">
|
||||
<IconCheck size={32} stroke={2} />
|
||||
</ThemeIcon>
|
||||
<Box ta="center">
|
||||
<Title order={3}>{t("publish.successTitle")}</Title>
|
||||
<Text c="dimmed" size="sm" maw={480} mt={6}>
|
||||
{t("publish.success")}
|
||||
</Text>
|
||||
</Box>
|
||||
<Paper withBorder radius="md" p="md" w="100%">
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon variant="light" color="blue" radius="md" size="lg">
|
||||
<IconBuildingStore size={20} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} size="sm">
|
||||
{t("publish.manageTitle")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" mt={2}>
|
||||
{t("publish.manageDescription")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group justify="center">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{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 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>
|
||||
{!user && (
|
||||
<Alert color="blue">
|
||||
<Group justify="space-between">
|
||||
<span>{t("publish.signInHint")}</span>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: Error) => {
|
||||
setError(cause.message);
|
||||
showErrorNotification({ title: t("signIn"), message: cause.message });
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
</Group>
|
||||
</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={7}
|
||||
/>
|
||||
<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>}
|
||||
<Divider />
|
||||
<Group justify="space-between" align="center">
|
||||
<Text c="dimmed" size="xs" maw={360}>
|
||||
{t("publish.publicationNote")}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("publish.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={createSubmission.isPending || definition.isFetching}
|
||||
disabled={
|
||||
!user ||
|
||||
!definition.data ||
|
||||
definition.isError ||
|
||||
title.trim().length < 3 ||
|
||||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
|
||||
}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
|
||||
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
|
||||
import type { WorkshopReport } from "@homarr/workshop/schema";
|
||||
|
||||
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
|
||||
|
||||
interface WorkshopReportModalProps {
|
||||
client: WorkshopBackend;
|
||||
submissionId: string;
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [explanation, setExplanation] = useState("");
|
||||
const report = useWorkshopReportMutation(client);
|
||||
|
||||
const close = () => {
|
||||
setExplanation("");
|
||||
report.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = () =>
|
||||
report.mutate(
|
||||
{ submission: submissionId, category, explanation },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("reportSent"),
|
||||
message: t("reportSentDescription"),
|
||||
});
|
||||
close();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
|
||||
<Stack>
|
||||
<Select
|
||||
label={t("reportReason")}
|
||||
value={category}
|
||||
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
|
||||
data={categories.map((value) => ({
|
||||
value,
|
||||
label: t(`reportCategory.${value}`),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("reportExplanation")}
|
||||
minRows={4}
|
||||
autosize
|
||||
value={explanation}
|
||||
onChange={(event) => setExplanation(event.currentTarget.value)}
|
||||
/>
|
||||
{report.error && <Alert color="red">{report.error.message}</Alert>}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={close}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
|
||||
{t("reportSend")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Box, Group, Image, Modal, Paper, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronLeft, IconChevronRight, IconZoomIn } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
|
||||
interface WorkshopScreenshotsProps {
|
||||
client: WorkshopBackend;
|
||||
submissionId: string;
|
||||
title: string;
|
||||
screenshots: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A large preview of the submission's screenshots with a thumbnail strip and a
|
||||
* click-to-zoom lightbox, so people can actually see what they are installing.
|
||||
*/
|
||||
export function WorkshopScreenshots({ client, submissionId, title, screenshots }: WorkshopScreenshotsProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const [index, setIndex] = useState(0);
|
||||
const [zoomOpened, zoomControls] = useDisclosure(false);
|
||||
|
||||
if (screenshots.length === 0) return null;
|
||||
|
||||
const active = screenshots[Math.min(index, screenshots.length - 1)];
|
||||
if (!active) return null;
|
||||
|
||||
const alt = (position: number) => t("screenshotAlt", { title, count: position + 1 });
|
||||
const step = (delta: number) => setIndex((current) => (current + delta + screenshots.length) % screenshots.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden", position: "relative" }}>
|
||||
<UnstyledButton onClick={zoomControls.open} w="100%" aria-label={t("zoomScreenshot")}>
|
||||
<Image
|
||||
src={client.fileUrl(submissionId, active, "1200x800")}
|
||||
alt={alt(index)}
|
||||
fit="contain"
|
||||
h={{ base: 220, sm: 380 }}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
<Tooltip label={t("zoomScreenshot")}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
pos="absolute"
|
||||
top={8}
|
||||
right={8}
|
||||
aria-label={t("zoomScreenshot")}
|
||||
onClick={zoomControls.open}
|
||||
>
|
||||
<IconZoomIn size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{screenshots.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
left={8}
|
||||
top="50%"
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
aria-label={t("previousScreenshot")}
|
||||
onClick={() => step(-1)}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
right={8}
|
||||
top="50%"
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
aria-label={t("nextScreenshot")}
|
||||
onClick={() => step(1)}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{screenshots.length > 1 && (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{screenshots.map((file, position) => (
|
||||
<UnstyledButton
|
||||
key={file}
|
||||
onClick={() => setIndex(position)}
|
||||
aria-label={alt(position)}
|
||||
aria-pressed={position === index}
|
||||
>
|
||||
<Box
|
||||
w={96}
|
||||
h={64}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
outline:
|
||||
position === index
|
||||
? "2px solid var(--mantine-primary-color-filled)"
|
||||
: "1px solid var(--mantine-color-default-border)",
|
||||
opacity: position === index ? 1 : 0.65,
|
||||
}}
|
||||
>
|
||||
<Image src={client.fileUrl(submissionId, file, "192x128")} alt="" h={64} w={96} fit="cover" />
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={zoomOpened}
|
||||
onClose={zoomControls.close}
|
||||
size="auto"
|
||||
centered
|
||||
padding="xs"
|
||||
title={alt(index)}
|
||||
styles={{ body: { display: "flex", justifyContent: "center" } }}
|
||||
>
|
||||
<Image
|
||||
src={client.fileUrl(submissionId, active)}
|
||||
alt={alt(index)}
|
||||
fit="contain"
|
||||
mah="80dvh"
|
||||
style={{ maxWidth: "min(90vw, 1400px)" }}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
|
||||
/**
|
||||
* Shared Workshop identity. Signing in is only needed to vote or report,
|
||||
* never to install, so every surface can render this without gating its content.
|
||||
*/
|
||||
export function useWorkshopSession() {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
|
||||
const signIn = () => {
|
||||
setPending(true);
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("signIn"),
|
||||
message: cause instanceof Error ? cause.message : t("signInError"),
|
||||
});
|
||||
})
|
||||
.finally(() => setPending(false));
|
||||
};
|
||||
|
||||
const signOut = () => {
|
||||
client.signOut();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return { client, user, pending, signIn, signOut };
|
||||
}
|
||||
|
||||
export type WorkshopSession = ReturnType<typeof useWorkshopSession>;
|
||||
|
||||
export function WorkshopAccountButton({ session }: { session: WorkshopSession }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
|
||||
if (!session.user) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconBrandGithub size={16} />}
|
||||
loading={session.pending}
|
||||
onClick={session.signIn}
|
||||
>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{session.user.name}
|
||||
</Text>
|
||||
<Button variant="subtle" color="gray" size="compact-sm" onClick={session.signOut}>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Box, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconFlag, IconPhoto } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
|
||||
|
||||
/** Aspect-ratio-preserving preview strip. Large enough to actually judge a widget by. */
|
||||
const PREVIEW_HEIGHT = 168;
|
||||
|
||||
interface WorkshopSubmissionCardProps {
|
||||
client: WorkshopBackend;
|
||||
item: WorkshopSubmissionSummary;
|
||||
/** Rendered in the card footer, e.g. score, external link and the install button. */
|
||||
actions: ReactNode;
|
||||
}
|
||||
|
||||
export function WorkshopSubmissionCard({ client, item, actions }: WorkshopSubmissionCardProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const screenshot = item.screenshots[0];
|
||||
|
||||
return (
|
||||
<Card component="li" withBorder radius="md" padding="md" h="100%">
|
||||
<Card.Section>
|
||||
{screenshot ? (
|
||||
<Image
|
||||
src={client.fileUrl(item.id, screenshot, "600x400")}
|
||||
h={PREVIEW_HEIGHT}
|
||||
fit="cover"
|
||||
alt={t("screenshotAlt", { title: item.title, count: 1 })}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
h={PREVIEW_HEIGHT}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ display: "grid", placeItems: "center" }}
|
||||
>
|
||||
<Stack gap={4} align="center">
|
||||
<IconPhoto size={28} stroke={1.5} opacity={0.5} />
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{item.type === "customCss" ? "CSS" : "JSX"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Card.Section>
|
||||
|
||||
<Stack gap="xs" mt="md" style={{ flex: 1 }}>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{item.outdated && (
|
||||
<Badge size="sm" variant="light" color="yellow" leftSection={<IconAlertTriangle size={11} />}>
|
||||
{t("outdated")}
|
||||
</Badge>
|
||||
)}
|
||||
{item.reportCount > 0 && (
|
||||
<Tooltip label={t("reportWarning", { count: item.reportCount })} multiline maw={320}>
|
||||
<Badge size="sm" variant="light" color="red" leftSection={<IconFlag size={11} />}>
|
||||
{t("reportCount", { count: item.reportCount })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Box>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("author", { name: item.authorName || t("communityMember") })}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed" lineClamp={2} style={{ flex: 1 }}>
|
||||
{item.description || t("noDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" align="center" gap="xs" wrap="nowrap" mt="md">
|
||||
{actions}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Alert, Button, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import type { StyleProp } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopBackend, WorkshopPage } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
|
||||
|
||||
import { WorkshopSubmissionCard } from "./workshop-submission-card";
|
||||
|
||||
interface WorkshopSubmissionGridProps {
|
||||
client: WorkshopBackend;
|
||||
query: UseQueryResult<WorkshopPage<WorkshopSubmissionSummary>>;
|
||||
ariaLabel: string;
|
||||
emptyState: ReactNode;
|
||||
cols?: StyleProp<number>;
|
||||
renderActions(item: WorkshopSubmissionSummary): ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading, failure, empty and loaded states for a page of Workshop submissions.
|
||||
* Shared so the widget browser and the Custom CSS picker behave identically.
|
||||
*/
|
||||
export function WorkshopSubmissionGrid({
|
||||
client,
|
||||
query,
|
||||
ariaLabel,
|
||||
emptyState,
|
||||
cols = { base: 1, sm: 2, lg: 3 },
|
||||
renderActions,
|
||||
}: WorkshopSubmissionGridProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<Skeleton key={index} h={320} radius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">{t("unavailable")}</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
loading={query.isFetching}
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!query.data?.items.length) return <>{emptyState}</>;
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
component="ul"
|
||||
aria-label={ariaLabel}
|
||||
cols={cols}
|
||||
spacing="md"
|
||||
m={0}
|
||||
p={0}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
{query.data.items.map((item) => (
|
||||
<WorkshopSubmissionCard key={item.id} client={client} item={item} actions={renderActions(item)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconArrowBigDown, IconArrowBigDownFilled, IconArrowBigUp, IconArrowBigUpFilled } from "@tabler/icons-react";
|
||||
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { useWorkshopUserVotesQuery, useWorkshopVoteMutation } from "@homarr/workshop/backend";
|
||||
|
||||
interface WorkshopVoteControlProps {
|
||||
client: WorkshopBackend;
|
||||
submissionId: string;
|
||||
score: number;
|
||||
/** Voting requires a Workshop account; installing does not. */
|
||||
canVote: boolean;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
/**
|
||||
* Upvote / net score / downvote. The user's own vote is filled and coloured,
|
||||
* and clicking it again removes the vote.
|
||||
*/
|
||||
export function WorkshopVoteControl({ client, submissionId, score, canVote, size = "md" }: WorkshopVoteControlProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const vote = useWorkshopVoteMutation(client);
|
||||
const userVotes = useWorkshopUserVotesQuery(client);
|
||||
const ownVote = userVotes.data?.find((entry) => entry.submission === submissionId)?.value ?? null;
|
||||
|
||||
const castVote = (value: 1 | -1) =>
|
||||
vote.mutate(
|
||||
{ submission: submissionId, value },
|
||||
{ onError: (error) => showErrorNotification({ title: t("voteError"), message: error.message }) },
|
||||
);
|
||||
|
||||
const iconSize = size === "sm" ? 16 : 18;
|
||||
const controlSize = size === "sm" ? "sm" : "md";
|
||||
const upvoted = ownVote === 1;
|
||||
const downvoted = ownVote === -1;
|
||||
|
||||
const controls = (
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<MaybeTooltip label={canVote ? t("upvote") : undefined}>
|
||||
<ActionIcon
|
||||
variant={upvoted ? "light" : "subtle"}
|
||||
color={upvoted ? "red" : "gray"}
|
||||
size={controlSize}
|
||||
aria-label={t("upvote")}
|
||||
aria-pressed={upvoted}
|
||||
loading={vote.isPending && vote.variables?.value === 1}
|
||||
disabled={!canVote || vote.isPending}
|
||||
onClick={() => castVote(1)}
|
||||
>
|
||||
{upvoted ? <IconArrowBigUpFilled size={iconSize} /> : <IconArrowBigUp size={iconSize} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
</MaybeTooltip>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
ta="center"
|
||||
miw={24}
|
||||
c={upvoted ? "red" : downvoted ? "blue" : undefined}
|
||||
aria-label={t("score", { count: score })}
|
||||
>
|
||||
{score}
|
||||
</Text>
|
||||
<MaybeTooltip label={canVote ? t("downvote") : undefined}>
|
||||
<ActionIcon
|
||||
variant={downvoted ? "light" : "subtle"}
|
||||
color={downvoted ? "blue" : "gray"}
|
||||
size={controlSize}
|
||||
aria-label={t("downvote")}
|
||||
aria-pressed={downvoted}
|
||||
loading={vote.isPending && vote.variables?.value === -1}
|
||||
disabled={!canVote || vote.isPending}
|
||||
onClick={() => castVote(-1)}
|
||||
>
|
||||
{downvoted ? <IconArrowBigDownFilled size={iconSize} /> : <IconArrowBigDown size={iconSize} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
</MaybeTooltip>
|
||||
</Group>
|
||||
);
|
||||
|
||||
// Tooltips never fire on disabled controls, so when voting is unavailable the
|
||||
// hint has to hang off the enabled wrapper instead of the buttons themselves.
|
||||
return canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>;
|
||||
}
|
||||
|
||||
const MaybeTooltip = ({ label, children }: { label: string | undefined; children: React.ReactElement }) =>
|
||||
label ? <Tooltip label={label}>{children}</Tooltip> : children;
|
||||
@@ -6,17 +6,42 @@ import { describe, expect, test } from "vitest";
|
||||
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
|
||||
|
||||
describe("Workshop navigation translations", () => {
|
||||
test("defines the navbar and breadcrumb label in the source locale", async () => {
|
||||
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
|
||||
management?: { navbar?: { items?: { workshop?: unknown } } };
|
||||
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
|
||||
navigationStructure?: {
|
||||
manage?: {
|
||||
workshop?: unknown;
|
||||
"custom-widgets"?: {
|
||||
workshop?: { label?: unknown };
|
||||
publish?: { label?: unknown };
|
||||
};
|
||||
};
|
||||
};
|
||||
customWidget?: { page?: { tabs?: Record<string, unknown> } };
|
||||
workshop?: Record<string, unknown>;
|
||||
};
|
||||
expect(messages.management?.navbar?.items?.workshop).toBe("Workshop");
|
||||
expect(messages.navigationStructure?.manage?.workshop?.label).toBe("Workshop");
|
||||
|
||||
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
|
||||
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
|
||||
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
|
||||
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
|
||||
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
|
||||
expect(messages.customWidget?.page?.tabs).toMatchObject({
|
||||
installed: "Installed",
|
||||
workshop: "Workshop",
|
||||
ariaLabel: expect.any(String),
|
||||
});
|
||||
|
||||
expect(messages.workshop).toMatchObject({
|
||||
communityMember: "Community member",
|
||||
outdated: "Outdated",
|
||||
install: "Install",
|
||||
back: expect.any(String),
|
||||
listAriaLabel: expect.any(String),
|
||||
installReviewTitle: expect.any(String),
|
||||
emptyFilteredDescription: expect.any(String),
|
||||
importCssConfirm: expect.any(String),
|
||||
reportCount: expect.any(String),
|
||||
reportWarning: expect.any(String),
|
||||
reportVisibility: expect.any(String),
|
||||
@@ -31,7 +56,19 @@ describe("Workshop navigation translations", () => {
|
||||
inappropriate: expect.any(String),
|
||||
other: expect.any(String),
|
||||
}),
|
||||
publish: expect.objectContaining({ invalidScreenshot: expect.any(String) }),
|
||||
publish: expect.objectContaining({
|
||||
invalidScreenshot: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("drops the message keys that only existed for the stacked install modals", async () => {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
|
||||
workshop?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
|
||||
expect(messages.workshop).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Badge, Box, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { ImportReview } from "../core/import";
|
||||
|
||||
export interface ImportReviewMessages {
|
||||
title: string;
|
||||
export interface ImportReviewContentMessages {
|
||||
description: string;
|
||||
name: string;
|
||||
origin: string;
|
||||
@@ -28,9 +15,13 @@ export interface ImportReviewMessages {
|
||||
permissions: string;
|
||||
actionWarningTitle: string;
|
||||
actionWarningDescription: string;
|
||||
permission(permission: string): string;
|
||||
}
|
||||
|
||||
export interface ImportReviewMessages extends ImportReviewContentMessages {
|
||||
title: string;
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
permission(permission: string): string;
|
||||
}
|
||||
|
||||
export interface ImportReviewDialogProps {
|
||||
@@ -75,6 +66,71 @@ function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface ImportReviewContentProps {
|
||||
review: ImportReview;
|
||||
messages: ImportReviewContentMessages;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The security facts a user has to review before a custom widget is installed.
|
||||
* Shared by the import modal and the Workshop install page so both read identically.
|
||||
*/
|
||||
export function ImportReviewContent({ review, messages, children }: ImportReviewContentProps) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{messages.description}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={messages.name} value={review.name} />
|
||||
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
|
||||
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.methods}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.permissions}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{messages.permission(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.actionWarningTitle}
|
||||
</Text>
|
||||
<Text size="sm">{messages.actionWarningDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportReviewDialog({
|
||||
opened,
|
||||
review,
|
||||
@@ -103,56 +159,9 @@ export function ImportReviewDialog({
|
||||
{review && (
|
||||
<>
|
||||
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{messages.description}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={messages.name} value={review.name} />
|
||||
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
|
||||
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.methods}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.methods.map((method) => (
|
||||
<Badge
|
||||
key={method}
|
||||
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
{messages.permissions}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{review.permissions.map((permission) => (
|
||||
<Badge key={permission} color="gray" variant="light">
|
||||
{messages.permission(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
{review.hasActions && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.actionWarningTitle}
|
||||
</Text>
|
||||
<Text size="sm">{messages.actionWarningDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<ImportReviewContent review={review} messages={messages}>
|
||||
{children}
|
||||
</Stack>
|
||||
</ImportReviewContent>
|
||||
</Box>
|
||||
<Box
|
||||
px="lg"
|
||||
|
||||
@@ -831,7 +831,12 @@
|
||||
"list": {
|
||||
"title": "Custom Widgets",
|
||||
"noResults": "No custom widgets yet. Create your first one!",
|
||||
"noDescription": "No description",
|
||||
"loading": "Loading...",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"disabledDescription": "This widget stays configured but will not load on any board.",
|
||||
"requestCount": "{count, plural, one {# request} other {# requests}}",
|
||||
"invalidDefinition": "Invalid definition",
|
||||
"invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.",
|
||||
"migrationRequired": "Migration required",
|
||||
@@ -846,6 +851,11 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Custom Widget"
|
||||
},
|
||||
"tabs": {
|
||||
"ariaLabel": "Custom widget sections",
|
||||
"installed": "Installed",
|
||||
"workshop": "Workshop"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -1382,69 +1392,68 @@
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"installSuccess": "Custom widget installed",
|
||||
"installSuccessDescription": "Add its credentials, test the requests, and save when it is ready.",
|
||||
"installError": "Could not install widget",
|
||||
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
|
||||
"title": "Workshop",
|
||||
"importFromWorkshop": "Import from Workshop",
|
||||
"browseWorkshop": "Browse Workshop",
|
||||
"openCommunity": "Open Workshop",
|
||||
"manageSubmissionsTitle": "Manage submissions on Workshop",
|
||||
"manageSubmissionsDescription": "Publishing starts in Homarr, but updates, reports, outdated status, and deletion are managed directly on the Workshop website. Sign in there and select Yours to find your submissions.",
|
||||
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
|
||||
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
|
||||
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
|
||||
"securityNoticeCss": "Community CSS can change the appearance and usability of a board. Review it before importing; nothing is saved until you save board settings.",
|
||||
"signIn": "Sign in with GitHub",
|
||||
"signInError": "GitHub sign-in failed. Check that the Workshop OAuth provider is configured and that popups are allowed.",
|
||||
"signOut": "Sign out",
|
||||
"openOnWorkshop": "Open {title} on the Workshop website",
|
||||
"back": "Back to Workshop",
|
||||
"cancel": "Cancel",
|
||||
"search": "Search widgets",
|
||||
"searchCss": "Search CSS themes",
|
||||
"sort": "Sort",
|
||||
"sortTop": "Top rated",
|
||||
"sortNewest": "Newest",
|
||||
"listAriaLabel": "Workshop widgets",
|
||||
"listAriaLabelCss": "Workshop CSS themes",
|
||||
"empty": "No widgets match this search.",
|
||||
"emptyDescription": "Community widgets published to the Workshop show up here.",
|
||||
"emptyFilteredTitle": "No widgets match this search",
|
||||
"emptyFilteredDescription": "No Workshop widget matches \"{search}\".",
|
||||
"clearSearch": "Clear search",
|
||||
"emptyCss": "No CSS themes match this search.",
|
||||
"unavailableTitle": "Homarr Workshop seems to be unavailable",
|
||||
"unavailable": "The community service may be temporarily down. Try again in a moment. Your installed widgets are unaffected.",
|
||||
"retry": "Try again",
|
||||
"empty": "No widgets match this search.",
|
||||
"emptyCss": "No CSS themes match this search.",
|
||||
"loadError": "This submission could not be loaded.",
|
||||
"noDescription": "No description",
|
||||
"author": "by {name}",
|
||||
"communityMember": "Community member",
|
||||
"comments": "{count, plural, one {# comment} other {# comments}}",
|
||||
"viewDetails": "View details",
|
||||
"voteSummary": "{upvotes, plural, one {# upvote} other {# upvotes}}, {downvotes, plural, one {# downvote} other {# downvotes}}",
|
||||
"outdated": "Outdated",
|
||||
"outdatedWarning": "The author marked this submission as outdated. Review it carefully before importing.",
|
||||
"reportCount": "{count, plural, one {# report} other {# reports}}",
|
||||
"reportWarning": "{count, plural, one {This submission has # open community report.} other {This submission has # open community reports.}} It remains installable, but you should review its content first.",
|
||||
"reportVisibility": "Report details are visible only to this submission's author and Workshop admins.",
|
||||
"screenshotAlt": "{title} screenshot {count, number}",
|
||||
"upvote": "Upvote, {count, plural, one {# vote} other {# votes}}",
|
||||
"downvote": "Downvote, {count, plural, one {# vote} other {# votes}}",
|
||||
"score": "Score: {count, number}",
|
||||
"inspectCss": "Inspect CSS",
|
||||
"upvote": "Upvote",
|
||||
"downvote": "Downvote",
|
||||
"voteError": "The vote could not be saved.",
|
||||
"signIn": "Sign in with GitHub",
|
||||
"signInError": "GitHub sign-in failed. Check that the Workshop OAuth provider is configured and that popups are allowed.",
|
||||
"signOut": "Sign out",
|
||||
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
|
||||
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
|
||||
"securityNoticeCss": "Community CSS can change the appearance and usability of a board. Review it before importing; nothing is saved until you save board settings.",
|
||||
"install": "Install",
|
||||
"installReviewTitle": "Review before installing",
|
||||
"installError": "This widget cannot be installed",
|
||||
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
|
||||
"useCss": "Import CSS",
|
||||
"importCss": "Import CSS from Workshop",
|
||||
"importCssConfirm": "Replace the current custom CSS with \"{title}\"? Nothing is saved until you save the board settings.",
|
||||
"screenshotAlt": "{title} screenshot {count, number}",
|
||||
"zoomScreenshot": "View full size",
|
||||
"previousScreenshot": "Previous screenshot",
|
||||
"nextScreenshot": "Next screenshot",
|
||||
"technicalDetails": "Technical details",
|
||||
"sourceCode": "Workshop entry",
|
||||
"loadError": "This submission could not be loaded.",
|
||||
"sources": "Declared API sources",
|
||||
"requests": "Declared queries and actions",
|
||||
"noRequests": "This widget does not declare network requests.",
|
||||
"export": "Export",
|
||||
"report": "Report",
|
||||
"install": "Install widget",
|
||||
"continueInstall": "Continue installation",
|
||||
"confirmInstallTitle": "Confirm widget installation",
|
||||
"confirmInstallDescription": "Confirm where this widget connects and add any credentials required by your Homarr installation.",
|
||||
"backToWidget": "Back to widget",
|
||||
"useCss": "Import CSS",
|
||||
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
|
||||
"reportTitle": "Report submission",
|
||||
"reportReason": "Reason",
|
||||
"reportExplanation": "What should the Workshop moderators know?",
|
||||
"reportSend": "Send report",
|
||||
"reportSent": "Report sent",
|
||||
"reportSentDescription": "Workshop moderators can now review this submission.",
|
||||
"reportCount": "{count, plural, one {# report} other {# reports}}",
|
||||
"reportWarning": "{count, plural, one {This submission has # open community report.} other {This submission has # open community reports.}} It remains installable, but you should review its content first.",
|
||||
"reportVisibility": "Report details are visible only to this submission's author and Workshop admins.",
|
||||
"reportCategory": {
|
||||
"outdated": "Outdated or no longer working",
|
||||
"malicious": "Malicious",
|
||||
@@ -1453,7 +1462,6 @@
|
||||
"inappropriate": "Inappropriate",
|
||||
"other": "Other"
|
||||
},
|
||||
"voteError": "The vote could not be saved.",
|
||||
"publish": {
|
||||
"title": "Publish to Workshop",
|
||||
"introTitle": "Share this widget with the community",
|
||||
@@ -1462,7 +1470,6 @@
|
||||
"listingDetailsDescription": "These details appear publicly alongside the exported widget definition.",
|
||||
"success": "Published immediately. Community members can now install and report this widget.",
|
||||
"successTitle": "Your widget is live",
|
||||
"manageTitle": "Continue on Workshop",
|
||||
"manageDescription": "Open the submission to update its listing, review reports, mark it outdated, or delete it. Submission management is handled directly on the Workshop website.",
|
||||
"viewSubmission": "View and manage submission",
|
||||
"done": "Done",
|
||||
@@ -1481,10 +1488,7 @@
|
||||
"publicationNote": "Publishing is immediate. You can manage the submission afterward on Workshop.",
|
||||
"definitionChanged": "The widget changed while publication was being prepared. Review its private source URLs again, then retry.",
|
||||
"error": "Publication failed"
|
||||
},
|
||||
"installDialog": "Install from Workshop",
|
||||
"installed": "Widget installed",
|
||||
"installedDescription": "{name} is ready to configure."
|
||||
}
|
||||
},
|
||||
"integration": {
|
||||
"page": {
|
||||
@@ -5970,7 +5974,6 @@
|
||||
"apps": "Apps",
|
||||
"integrations": "Integrations",
|
||||
"customWidgets": "Custom Widgets",
|
||||
"workshop": "Workshop",
|
||||
"searchEngies": "Search engines",
|
||||
"medias": "Medias",
|
||||
"users": {
|
||||
@@ -7350,11 +7353,14 @@
|
||||
"flow": {
|
||||
"label": "Flow"
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Workshop"
|
||||
},
|
||||
"publish": {
|
||||
"label": "Publish to Workshop"
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Workshop"
|
||||
},
|
||||
"users": {
|
||||
"label": "Users",
|
||||
"create": {
|
||||
|
||||
@@ -657,6 +657,8 @@ export const workshopQueryKeys = {
|
||||
] as const,
|
||||
comments: (backend: WorkshopBackend, submission: string) =>
|
||||
[...workshopQueryKeys.all, "comments", backend.baseUrl, submission] as const,
|
||||
userVotes: (backend: WorkshopBackend) =>
|
||||
[...workshopQueryKeys.all, "user-votes", backend.baseUrl, backend.currentUser?.id ?? "anonymous"] as const,
|
||||
};
|
||||
|
||||
export const workshopListQueryOptions = (backend: WorkshopBackend, options: WorkshopListOptions = {}) =>
|
||||
@@ -683,6 +685,14 @@ export const workshopReportSummariesQueryOptions = (backend: WorkshopBackend, su
|
||||
enabled: submission.length > 0,
|
||||
});
|
||||
|
||||
/** The votes the signed-in user already cast, so controls can render their own choice. */
|
||||
export const workshopUserVotesQueryOptions = (backend: WorkshopBackend) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.userVotes(backend),
|
||||
queryFn: () => backend.listVotesForCurrentUser(),
|
||||
enabled: backend.currentUser !== null,
|
||||
});
|
||||
|
||||
export const workshopCommentsQueryOptions = (backend: WorkshopBackend, submission: string) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.comments(backend, submission),
|
||||
@@ -771,12 +781,17 @@ export function useWorkshopCommentsQuery(backend: WorkshopBackend, submission: s
|
||||
return useQuery(workshopCommentsQueryOptions(backend, submission));
|
||||
}
|
||||
|
||||
export function useWorkshopUserVotesQuery(backend: WorkshopBackend) {
|
||||
return useQuery(workshopUserVotesQueryOptions(backend));
|
||||
}
|
||||
|
||||
function useWorkshopInvalidation(backend: WorkshopBackend) {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
lists: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.lists(backend.baseUrl) }),
|
||||
details: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.details(backend.baseUrl) }),
|
||||
detail: (id: string) => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
|
||||
userVotes: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.userVotes(backend) }),
|
||||
removeDetail: (id: string) => queryClient.removeQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
|
||||
reports: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reports(backend) }),
|
||||
reportSummaries: (submission: string) =>
|
||||
@@ -814,7 +829,7 @@ export function useWorkshopVoteMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopVoteMutationOptions(backend),
|
||||
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details()]),
|
||||
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.userVotes()]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user