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:
Thomas Camlong committed 2026-08-17 01:14:38 +02:00
1 parent 50537d077c
commit 206905ea2d
38 files changed
+2303 -1700

No files matched your search

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