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