feat: improve workshop publishing flow

This commit is contained in:
Thomas Camlong committed 2026-08-14 23:16:39 +02:00
1 parent 168091134d
commit b491d45fdc
19 files changed
+254 -82

No files matched your search

@@ -17,6 +17,15 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
});
it("uses the runtime Workshop web URL independently from the API origin", () => {
document.head.innerHTML = [
'<meta name="homarr-workshop-api-url" content="https://api.example.com/">',
'<meta name="homarr-workshop-web-url" content="https://community.example.com/workshop">',
].join("");
expect(getWorkshopWebUrl()).toBe("https://community.example.com/workshop");
});
it("derives the Homarr provider endpoint from the runtime Workshop API", () => {
document.head.innerHTML = '<meta name="homarr-workshop-api-url" content="https://api.preview.example/">';
@@ -1,7 +1,24 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
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";
@@ -10,7 +27,7 @@ import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
@@ -34,7 +51,7 @@ export function WorkshopPublishModal({
const [screenshots, setScreenshots] = useState<File[]>([]);
const createSubmission = useWorkshopCreateMutation(client);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
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);
@@ -51,7 +68,7 @@ export function WorkshopPublishModal({
setDescription("");
setScreenshots([]);
setError(null);
setPublished(false);
setPublishedSubmissionId(null);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
@@ -64,11 +81,12 @@ export function WorkshopPublishModal({
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: (content) =>
createSubmission.mutateAsync({
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
input: {
type: "customWidget",
title,
@@ -76,7 +94,9 @@ export function WorkshopPublishModal({
content,
},
screenshots,
}),
});
submissionId = submission.id;
},
});
if (result === "unavailable") throw new Error(t("publish.error"));
if (result === "changed") {
@@ -84,19 +104,72 @@ export function WorkshopPublishModal({
setError(t("publish.definitionChanged"));
return;
}
setPublished(true);
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">
<Stack>
{published ? (
<Alert color="green">{t("publish.success")}</Alert>
<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">
@@ -118,28 +191,45 @@ export function WorkshopPublishModal({
</Group>
</Alert>
)}
<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={3}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
/>
<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">
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
sources: privateSourceNames.join(", "),
})}
@@ -153,19 +243,30 @@ export function WorkshopPublishModal({
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<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>
<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>