diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx
index a8c3e153c..24357f761 100644
--- a/apps/docs/docs/management/workshop/index.mdx
+++ b/apps/docs/docs/management/workshop/index.mdx
@@ -35,7 +35,7 @@ Back up your instance before installing untrusted content.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
-- Open report counts are public on submission cards so visitors can make an informed installation decision. Report explanations and reporter identities remain visible only to Workshop moderators.
+- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
- The manual **Outdated** badge warns visitors that a submission may need an update.
@@ -53,6 +53,9 @@ Comments are publicly readable and appear in chronological conversation bubbles
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
4. Submit the entry.
+Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
+also formats older compact submissions before displaying them.
+
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
@@ -80,8 +83,7 @@ You can install workshop entries without leaving Homarr:
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
-- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
- pick a theme to load it into the editor before saving.
+- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx
index f28c8af9b..579dd65a9 100644
--- a/apps/docs/src/components/workshop/DetailPage.tsx
+++ b/apps/docs/src/components/workshop/DetailPage.tsx
@@ -35,6 +35,7 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
githubProfileUrl,
+ formatWorkshopContent,
MAX_WORKSHOP_SCREENSHOTS,
MAX_WORKSHOP_SCREENSHOT_BYTES,
WORKSHOP_SCREENSHOT_MIME_TYPES,
@@ -534,6 +535,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const requests = widgetDefinition ? Object.entries(widgetDefinition.requests) : [];
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
+ const displayedContent = formatWorkshopContent(submission.type, submission.content);
return (
@@ -697,12 +699,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
) : (
-
+
)}
diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx
index 548c4c7d2..22692c8cf 100644
--- a/apps/nextjs/src/components/workshop/workshop-browser.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx
@@ -43,6 +43,7 @@ import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbenc
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
+ useWorkshopReportSummariesQuery,
useWorkshopReportMutation,
useWorkshopSubmissionQuery,
useWorkshopVoteMutation,
@@ -108,7 +109,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState
(null);
- const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState(null);
const workshopWebUrl = useMemo(getWorkshopWebUrl, []);
const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
const reportOpened = modalStack?.report.opened ?? internalReportOpened;
@@ -131,6 +131,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
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"
@@ -154,10 +155,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall);
- const useCss = useWorkshopCssImportMutation(onUseCss, () => {
- setCssAwaitingConfirmation(null);
- setSelectedId(null);
- });
+ const useCss = useWorkshopCssImportMutation(onUseCss, () => setSelectedId(null));
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
@@ -372,11 +370,28 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
)}
{detail.data.reportCount > 0 && (
- }>
- This submission has {detail.data.reportCount} open community report
- {detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
- first.
-
+
+ }>
+ This submission has {detail.data.reportCount} open community report
+ {detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
+ first.
+
+ {reportSummaries.data?.map((summary) => (
+
+
+ {summary.category}
+
+
+ {summary.explanation}
+
+
+ ))}
+ {reportSummaries.data?.length === 0 && (
+
+ Report details are visible only to this submission's author and Workshop admins.
+
+ )}
+
)}
{detail.data.screenshots.length > 0 && (
@@ -457,11 +472,12 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
)}
{onUseCss && (
detailValidation?.success &&
typeof detailValidation.data === "string" &&
- setCssAwaitingConfirmation(detailValidation.data)
+ useCss.mutate(detailValidation.data)
}
>
{t("useCss")}
@@ -507,31 +523,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
- setCssAwaitingConfirmation(null)}
- title="Import this Custom CSS?"
- >
-
- }>
- Review community CSS before applying it. Importing replaces the current Custom CSS editor value, but nothing
- is saved until you save the board settings.
-
-
- setCssAwaitingConfirmation(null)}>
- Cancel
-
- {
- if (cssAwaitingConfirmation !== null) useCss.mutate(cssAwaitingConfirmation);
- }}
- >
- Import CSS
-
-
-
-
);
}
diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
index 0c79f728f..8a5e2cf21 100644
--- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
@@ -1,30 +1,60 @@
"use client";
-import { useState } from "react";
-import { Button, Modal } from "@mantine/core";
+import { Button, Modal, useModalsStack } from "@mantine/core";
+import type { ModalProps } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
-import { modalSizeSelect } from "@homarr/modals";
-
import { WorkshopBrowser } from "./workshop-browser";
+const cssInstallModalProps = {
+ size: "90%",
+ styles: {
+ content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
+ body: { flex: 1, overflowY: "auto" },
+ },
+} satisfies Pick;
+
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
- const [opened, setOpened] = useState(false);
+ const stack = useModalsStack(["workshop", "details", "report"]);
+ const detailsModal = stack.register("details");
+ const reportModal = stack.register("report");
return (
<>
- } onClick={() => setOpened(true)}>
+ }
+ onClick={() => stack.open("workshop")}
+ >
Import from Workshop
- setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
- {
- onImport(css);
- setOpened(false);
- }}
- />
-
+
+
+ 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();
+ }}
+ />
+
+
>
);
}
diff --git a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts
index 2415d0370..2d24f49c6 100644
--- a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts
+++ b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts
@@ -31,4 +31,18 @@ describe("Workshop widget install flow", () => {
expect(browser).toContain("ReadOnlyCustomWidgetCode");
expect(browser).not.toContain("{item.widgetSchema}");
});
+
+ 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");
+
+ expect(cssButton).toContain('useModalsStack(["workshop", "details", "report"])');
+ expect(cssButton).toContain("");
+ expect(cssButton).toContain('height: "min(85dvh, 900px)"');
+ expect(browser).not.toContain("cssAwaitingConfirmation");
+ expect(browser).toContain("useWorkshopReportSummariesQuery");
+ });
});
diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts
index d6040c9da..69a72e689 100644
--- a/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts
+++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts
@@ -26,6 +26,12 @@ describe("Workshop publish definition inspection", () => {
expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true);
});
+ it("publishes readable multi-line JSON", () => {
+ const serialized = serializeWorkshopDefinition(definition);
+ expect(serialized).toContain('\n "name": "Status"');
+ expect(JSON.parse(serialized)).toEqual(definition);
+ });
+
it("blocks publishing when the definition changes after inspection", async () => {
const publish = vi.fn(async () => undefined);
await expect(
diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts
index b3aa9c9be..c17740854 100644
--- a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts
+++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts
@@ -3,7 +3,7 @@ interface PublishableWorkshopDefinition {
}
export const serializeWorkshopDefinition = (definition: unknown): string => {
- const serialized = JSON.stringify(definition);
+ const serialized = JSON.stringify(definition, null, 2);
if (serialized === undefined) throw new Error("Workshop definition is not serializable");
return serialized;
};
diff --git a/apps/workshop/README.md b/apps/workshop/README.md
index 35cd25b1b..714eef157 100644
--- a/apps/workshop/README.md
+++ b/apps/workshop/README.md
@@ -9,7 +9,8 @@ version and digest together, then run the Workshop integration and production-im
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
access. Signed-in moderators can open `/workshop/admin` from the visible **Moderation** button, review private report
-details, dismiss reports, and remove submissions or comments. Public submission cards show only the open report count.
+details, dismiss reports, and remove submissions or comments. Public submission cards show only the open report count;
+the submission author can read the report category and explanation without receiving the reporter identity.
## Run locally
diff --git a/apps/workshop/pb_migrations/1786381200_report_summaries.js b/apps/workshop/pb_migrations/1786381200_report_summaries.js
new file mode 100644
index 000000000..4523d9964
--- /dev/null
+++ b/apps/workshop/pb_migrations/1786381200_report_summaries.js
@@ -0,0 +1,24 @@
+///
+
+const reportReaderRule = "@request.auth.id != '' && (author = @request.auth.id || @request.auth.isAdmin = true)";
+
+migrate(
+ (app) => {
+ const summaries = new Collection({
+ type: "view",
+ name: "workshop_report_summaries",
+ listRule: reportReaderRule,
+ viewRule: reportReaderRule,
+ viewQuery: `
+ SELECT r.id, r.submission, s.author, r.category, r.explanation, r.created
+ FROM reports r
+ INNER JOIN submissions s ON s.id = r.submission
+ WHERE r.status = 'open'
+ `,
+ });
+ app.save(summaries);
+ },
+ (app) => {
+ app.delete(app.findCollectionByNameOrId("workshop_report_summaries"));
+ },
+);
diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs
index 90a6102b4..8dab3d099 100644
--- a/apps/workshop/tests/workshop.integration.mjs
+++ b/apps/workshop/tests/workshop.integration.mjs
@@ -28,7 +28,14 @@ const root = await request("/api/collections/_superusers/auth-with-password", {
const rootHeaders = { authorization: `Bearer ${root.token}` };
const collections = await request("/api/collections?perPage=200", { headers: rootHeaders });
const collectionNames = new Set(collections.items.map((collection) => collection.name));
-for (const required of ["submissions", "votes", "comments", "reports", "workshop_listings"]) {
+for (const required of [
+ "submissions",
+ "votes",
+ "comments",
+ "reports",
+ "workshop_listings",
+ "workshop_report_summaries",
+]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
@@ -278,6 +285,23 @@ await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: v
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop moderators");
+const visitorReportSummaries = await request("/api/collections/workshop_report_summaries/records", {
+ headers: visitorSession.headers,
+});
+if (visitorReportSummaries.items.length !== 0) {
+ throw new Error("Report explanations must remain private from unrelated Workshop users");
+}
+const authorReportSummaries = await request("/api/collections/workshop_report_summaries/records", {
+ headers: authorSession.headers,
+});
+if (
+ authorReportSummaries.items.length !== 1 ||
+ authorReportSummaries.items[0].submission !== submission.id ||
+ authorReportSummaries.items[0].explanation !== "Runtime moderation test" ||
+ "reporter" in authorReportSummaries.items[0]
+) {
+ throw new Error("Submission authors must see report reasons without exposing reporter identities");
+}
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
diff --git a/packages/workshop/src/backend.ts b/packages/workshop/src/backend.ts
index 5afb98289..33b2605e7 100644
--- a/packages/workshop/src/backend.ts
+++ b/packages/workshop/src/backend.ts
@@ -14,6 +14,7 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type {
WorkshopComment,
WorkshopReport,
+ WorkshopReportSummary,
WorkshopSubmissionDetail,
WorkshopSubmissionInput,
WorkshopSubmissionSummary,
@@ -28,6 +29,7 @@ import {
WORKSHOP_SCHEMA_BY_TYPE,
workshopCommentSchema,
workshopReportSchema,
+ workshopReportSummarySchema,
workshopSubmissionDetailSchema,
workshopSubmissionSummarySchema,
workshopSubmissionInputSchema,
@@ -122,6 +124,13 @@ export interface WorkshopReportRecord extends WorkshopBaseRecord {
expand?: { reporter?: WorkshopUserRecord; submission?: WorkshopSubmissionRecord };
}
+export interface WorkshopReportSummaryRecord extends WorkshopBaseRecord {
+ submission: string;
+ category: WorkshopReportSummary["category"];
+ explanation: string;
+ created: string;
+}
+
export interface WorkshopCommentRecord extends WorkshopBaseRecord {
submission: string;
author: string;
@@ -137,6 +146,7 @@ export interface TypedWorkshopPocketBase extends PocketBase {
collection(idOrName: "workshop_listings"): RecordService;
collection(idOrName: "votes"): RecordService;
collection(idOrName: "reports"): RecordService;
+ collection(idOrName: "workshop_report_summaries"): RecordService;
collection(idOrName: "comments"): RecordService;
collection(idOrName: string): RecordService;
}
@@ -508,6 +518,18 @@ export class WorkshopBackend {
}
}
+ public async listReportSummaries(submission: string): Promise {
+ try {
+ const filter = this.pocketBase.filter("submission = {:submission}", { submission });
+ const items = await this.pocketBase
+ .collection("workshop_report_summaries")
+ .getFullList({ batch: 100, filter, sort: "-created" });
+ return items.map((item) => workshopReportSummarySchema.parse(item));
+ } catch (error) {
+ throw workshopError(error, "Failed to load report details");
+ }
+ }
+
public async dismissReport(id: string) {
try {
await this.pocketBase.collection("reports").update(id, { status: "dismissed" });
@@ -599,6 +621,14 @@ export const workshopQueryKeys = {
details: (baseUrl?: string) => [...workshopQueryKeys.all, "detail", baseUrl] as const,
detail: (backend: WorkshopBackend, id: string) => [...workshopQueryKeys.details(backend.baseUrl), id] as const,
reports: (backend: WorkshopBackend) => [...workshopQueryKeys.all, "reports", backend.baseUrl] as const,
+ reportSummaries: (backend: WorkshopBackend, submission: string) =>
+ [
+ ...workshopQueryKeys.all,
+ "report-summaries",
+ backend.baseUrl,
+ submission,
+ backend.currentUser?.id ?? "anonymous",
+ ] as const,
comments: (backend: WorkshopBackend, submission: string) =>
[...workshopQueryKeys.all, "comments", backend.baseUrl, submission] as const,
};
@@ -620,6 +650,13 @@ export const workshopSubmissionQueryOptions = (backend: WorkshopBackend, id: str
export const workshopReportsQueryOptions = (backend: WorkshopBackend) =>
queryOptions({ queryKey: workshopQueryKeys.reports(backend), queryFn: () => backend.listReports() });
+export const workshopReportSummariesQueryOptions = (backend: WorkshopBackend, submission: string) =>
+ queryOptions({
+ queryKey: workshopQueryKeys.reportSummaries(backend, submission),
+ queryFn: () => backend.listReportSummaries(submission),
+ enabled: submission.length > 0,
+ });
+
export const workshopCommentsQueryOptions = (backend: WorkshopBackend, submission: string) =>
queryOptions({
queryKey: workshopQueryKeys.comments(backend, submission),
@@ -700,6 +737,10 @@ export function useWorkshopReportsQuery(backend: WorkshopBackend) {
return useQuery(workshopReportsQueryOptions(backend));
}
+export function useWorkshopReportSummariesQuery(backend: WorkshopBackend, submission: string) {
+ return useQuery(workshopReportSummariesQueryOptions(backend, submission));
+}
+
export function useWorkshopCommentsQuery(backend: WorkshopBackend, submission: string) {
return useQuery(workshopCommentsQueryOptions(backend, submission));
}
@@ -712,6 +753,8 @@ function useWorkshopInvalidation(backend: WorkshopBackend) {
detail: (id: string) => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
removeDetail: (id: string) => queryClient.removeQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
reports: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reports(backend) }),
+ reportSummaries: (submission: string) =>
+ queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reportSummaries(backend, submission) }),
comments: (submission: string) =>
queryClient.invalidateQueries({ queryKey: workshopQueryKeys.comments(backend, submission) }),
};
@@ -753,7 +796,13 @@ export function useWorkshopReportMutation(backend: WorkshopBackend) {
const invalidation = useWorkshopInvalidation(backend);
return useMutation({
...workshopReportMutationOptions(backend),
- onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.reports()]),
+ onSuccess: async (_report, input) =>
+ Promise.all([
+ invalidation.lists(),
+ invalidation.details(),
+ invalidation.reports(),
+ invalidation.reportSummaries(input.submission),
+ ]),
});
}
diff --git a/packages/workshop/src/schema.spec.ts b/packages/workshop/src/schema.spec.ts
index 07e6adb40..1fcc13a03 100644
--- a/packages/workshop/src/schema.spec.ts
+++ b/packages/workshop/src/schema.spec.ts
@@ -57,6 +57,17 @@ describe("Workshop widget validation", () => {
).toBe(true);
});
+ test("normalizes widget submissions to readable multi-line JSON", () => {
+ const parsed = workshopSubmissionInputSchema.parse({
+ type: "customWidget",
+ title: "Starter widget",
+ description: "",
+ content: JSON.stringify(CUSTOM_WIDGET_STARTER),
+ });
+ expect(parsed.content).toContain('\n "$schema":');
+ expect(JSON.parse(parsed.content)).toEqual(CUSTOM_WIDGET_STARTER);
+ });
+
test("accepts Custom CSS without treating it as widget JSON", () => {
expect(
workshopSubmissionInputSchema.safeParse({
diff --git a/packages/workshop/src/schema.ts b/packages/workshop/src/schema.ts
index 34679ae03..8fe41386b 100644
--- a/packages/workshop/src/schema.ts
+++ b/packages/workshop/src/schema.ts
@@ -156,7 +156,8 @@ export const workshopSubmissionInputSchema = z
.superRefine((input, context) => {
const result = validateWorkshopContent(input.type, input.content);
if (!result.success) context.addIssue({ code: "custom", path: ["content"], message: result.error });
- });
+ })
+ .transform((input) => ({ ...input, content: formatWorkshopContent(input.type, input.content) }));
export type WorkshopSubmissionInput = z.input;
export const workshopScreenshotsSchema = z
@@ -190,6 +191,15 @@ export const workshopReportSchema = z.object({
});
export type WorkshopReport = z.infer;
+export const workshopReportSummarySchema = z.object({
+ id: z.string(),
+ submission: z.string(),
+ category: workshopReportCategorySchema,
+ explanation: z.string(),
+ created: z.string(),
+});
+export type WorkshopReportSummary = z.infer;
+
export const workshopCommentSchema = z.object({
id: z.string(),
submission: z.string(),
@@ -219,6 +229,15 @@ export function validateWorkshopContent(type: WorkshopSubmissionType, content: s
return validateWorkshopWidget(content);
}
+export function formatWorkshopContent(type: WorkshopSubmissionType, content: string): string {
+ if (type !== "customWidget") return content;
+ try {
+ return JSON.stringify(JSON.parse(content) as unknown, null, 2);
+ } catch {
+ return content;
+ }
+}
+
export function validateWorkshopWidget(content: string): WorkshopWidgetValidationResult {
try {
const parsed: unknown = JSON.parse(content);