Merge commit '378bc0cd2' into feat/propagate-workshop-admin-dnd

This commit is contained in:
Thomas Camlong committed 2026-08-10 17:37:29 +02:00
commit 3ac76491c6
21 files changed
+174 -132

No files matched your search

@@ -25,10 +25,10 @@ Every option has a default. Supported controls include text, textarea, number, s
## ChatGPT or Claude
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
Open **Create or fix with any AI**, enter the desired widget and optional API documentation URL, then select the copy action beside the accordion title. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
field. Copy that single block and select **Paste**. Homarr fills the visual builders and the JSX editor
without any placeholder replacement or second paste.
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
+5 -1
View File
@@ -35,7 +35,8 @@ 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.
- Report details and reporter identities are visible only to Workshop moderators. Reported submissions are highlighted in the moderation page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a 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.
- 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.
## Comments
@@ -52,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.
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.
Submissions are published immediately. Reported entries are reviewed and may be removed.
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
+1
View File
@@ -402,6 +402,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
path: "/workshop/:id",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
priority: -1,
});
},
};
@@ -451,7 +451,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
try {
await backend.report(
submission.id,
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportCategory as "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportExplanation,
);
setReportOpen(false);
@@ -881,6 +881,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<SelectValue>{(value) => String(value).replace(/^./u, (letter) => letter.toUpperCase())}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="outdated">Outdated or no longer working</SelectItem>
<SelectItem value="malicious">Malicious</SelectItem>
<SelectItem value="spam">Spam</SelectItem>
<SelectItem value="copyright">Copyright</SelectItem>
@@ -109,7 +109,11 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
submission,
reports: reports.filter((report) => report.submission === submission.id),
}))
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
.sort(
(left, right) =>
right.reports.length - left.reports.length ||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
);
return (
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
@@ -8,12 +8,14 @@ import {
IconChevronLeft,
IconChevronRight,
IconEye,
IconFlag,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconRefresh,
IconSearch,
IconShield,
} from "@tabler/icons-react";
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
@@ -116,6 +118,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const availableTypeFilters = workshop.user
? [...typeFilters, { value: "yours" as const, label: "Yours" }]
: typeFilters;
const openReportCount = workshop.submissions.reduce((total, submission) => total + submission.reportCount, 0);
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
@@ -126,9 +129,19 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
</p>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
<div className="flex flex-wrap items-center gap-2 self-start sm:self-auto">
{workshop.user ? (
<>
{workshop.user.isAdmin && (
<Button
variant="outline"
className="h-10 sm:h-8"
nativeButton={false}
render={<a href="/workshop/admin" aria-label={`Open moderation, ${openReportCount} open reports`} />}
>
<IconShield size={14} /> Moderation{openReportCount > 0 ? ` (${openReportCount})` : ""}
</Button>
)}
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
@@ -406,9 +419,14 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</CardHeader>
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
{submission.outdated && (
{(submission.outdated || submission.reportCount > 0) && (
<div className="mb-2 flex flex-wrap gap-1.5">
<Badge variant="secondary">Outdated</Badge>
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && (
<Badge variant="destructive" className="gap-1">
<IconFlag size={12} /> {submission.reportCount} {submission.reportCount === 1 ? "report" : "reports"}
</Badge>
)}
</div>
)}
{submission.description && (
@@ -162,7 +162,7 @@ export const useWorkshop = (workshopUrl: string) => {
const report = useCallback(
async (
submissionId: string,
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
category: "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => {
try {
@@ -30,7 +30,13 @@ describe("Copy AI prompt", () => {
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
expect(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
@@ -1,7 +1,7 @@
"use client";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCopy } from "@tabler/icons-react";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -36,15 +36,16 @@ export const CopyAiPromptButton = ({
};
return (
<Button
type="button"
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={() => void handleCopy()}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
<Tooltip label={t("action.copyAiPrompt")}>
<ActionIcon
type="button"
variant="subtle"
size="lg"
aria-label={t("action.copyAiPrompt")}
onClick={() => void handleCopy()}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
);
};
@@ -1,7 +1,7 @@
"use client";
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
@@ -20,38 +20,41 @@ interface AiCardProps {
export function CustomWidgetAiCard(props: AiCardProps) {
const t = useScopedI18n("customWidget.workbench.ai");
return (
<Card withBorder p="md">
<Stack gap="sm">
<Group gap="xs">
<IconSparkles size={18} />
<Text fw={600}>{t("title")}</Text>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
autosize
minRows={2}
/>
<TextInput
label={t("documentationUrl")}
value={props.documentationUrl}
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Accordion variant="contained">
<Accordion.Item value="ai">
<Group gap={0} wrap="nowrap">
<Accordion.Control icon={<IconSparkles size={18} />} style={{ flex: 1 }}>
{t("title")}
</Accordion.Control>
<CopyAiPromptButton
currentConfig={props.candidate}
request={props.request}
documentationUrl={props.documentationUrl}
/>
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
</SimpleGrid>
</Stack>
</Card>
</Group>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Textarea
label={t("request")}
value={props.request}
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
autosize
minRows={2}
/>
<TextInput
label={t("documentationUrl")}
value={props.documentationUrl}
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
/>
<Button type="button" variant="light" leftSection={<IconClipboard size={16} />} onClick={props.onPaste}>
{t("paste")}
</Button>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
@@ -67,7 +67,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
const [documentationUrl, setDocumentationUrl] = useState("");
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
const [previewSize, setPreviewSize] = useState("standard");
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
const initialDefinition = buildDefinition(formInitialValues);
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
@@ -267,8 +266,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
preview={preview}
size={previewSize}
onSizeChange={setPreviewSize}
theme={previewTheme}
onThemeChange={setPreviewTheme}
optionsSnapshot={optionsSnapshot}
onOptionsChange={setOptionsSnapshot}
onLiveActionsChange={(enabled) =>
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -69,6 +70,16 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -79,8 +90,6 @@ describe("Custom Widget preview panel", () => {
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -124,6 +133,8 @@ describe("Custom Widget preview panel", () => {
expect(host.textContent).toContain("sources.0.baseUrl");
expect(host.textContent).toContain("Enter a complete URL.");
expect(host.textContent).not.toContain("sessionActive");
expect(host.textContent).not.toContain("simulationPending");
});
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
@@ -149,8 +160,6 @@ describe("Custom Widget preview panel", () => {
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{ ...failingOptions }}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -175,8 +184,6 @@ describe("Custom Widget preview panel", () => {
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={failingOptions}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -211,8 +218,6 @@ describe("Custom Widget preview panel", () => {
preview={preview}
size="standard"
onSizeChange={vi.fn()}
theme="dark"
onThemeChange={vi.fn()}
optionsSnapshot={{}}
onOptionsChange={vi.fn()}
onLiveActionsChange={vi.fn()}
@@ -1,20 +1,7 @@
"use client";
import { useState } from "react";
import {
Alert,
Badge,
Box,
Card,
Group,
MantineProvider,
Paper,
SegmentedControl,
Stack,
Switch,
Tabs,
Text,
} from "@mantine/core";
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -44,8 +31,6 @@ interface PreviewPanelProps {
preview: PreviewState;
size: string;
onSizeChange(value: string): void;
theme: "light" | "dark";
onThemeChange(value: "light" | "dark"): void;
optionsSnapshot: Record<string, unknown>;
onOptionsChange(value: Record<string, unknown>): void;
onLiveActionsChange(enabled: boolean): void;
@@ -98,39 +83,51 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
return (
<Card withBorder p="md">
<Stack gap="md">
<Group grow>
<Group gap="xs" justify="space-between">
<SegmentedControl
size="xs"
value={props.size}
onChange={props.onSizeChange}
data={[
{ value: "compact", label: t("size.compact") },
{ value: "standard", label: t("size.standard") },
{ value: "wide", label: t("size.wide") },
{
value: "compact",
label: (
<span aria-label={t("size.compact")} title={t("size.compact")}>
S
</span>
),
},
{
value: "standard",
label: (
<span aria-label={t("size.standard")} title={t("size.standard")}>
M
</span>
),
},
{
value: "wide",
label: (
<span aria-label={t("size.wide")} title={t("size.wide")}>
L
</span>
),
},
]}
/>
<SegmentedControl
size="xs"
value={props.theme}
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "light", label: t("theme.light") },
{ value: "dark", label: t("theme.dark") },
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
</Group>
<SegmentedControl
size="xs"
value={fixture}
disabled={!candidate}
onChange={(value) => setFixture(value as typeof fixture)}
data={[
{ value: "live", label: t("fixture.live") },
{ value: "loading", label: t("fixture.loading") },
{ value: "empty", label: t("fixture.empty") },
{ value: "error", label: t("fixture.error") },
]}
/>
{candidate && (
<PreviewResult
outcome={props.preview.outcome}
@@ -153,22 +150,20 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
<Box className={classes.previewCanvas}>
<MantineProvider forceColorScheme={props.theme}>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</MantineProvider>
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
{displayData ? (
<PreviewErrorBoundary
title={errorBoundaryMessages("title")}
description={t("containedFailures")}
retryLabel={errorBoundaryMessages("retry")}
resetKeys={[rendererResetKey]}
>
<CustomJsxDisplay data={displayData} />
</PreviewErrorBoundary>
) : (
<Alert color="yellow">{t("invalid")}</Alert>
)}
</Paper>
</Box>
</Tabs.Panel>
<Tabs.Panel value="data" pt="sm">
@@ -221,11 +216,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
</Tabs.Panel>
<Tabs.Panel value="diagnostics" pt="sm">
<Stack gap="xs">
{props.preview.session ? (
<Badge color="green">{t("sessionActive")}</Badge>
) : (
<Badge color="gray">{t("simulationPending")}</Badge>
)}
<Text size="sm" c="dimmed">
{t("containedFailures")}
</Text>
@@ -430,7 +430,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
data={["outdated", "malicious", "spam", "copyright", "inappropriate", "other"]}
allowDeselect={false}
/>
<Textarea
+2 -1
View File
@@ -8,7 +8,8 @@ The runtime base is PocketBase 0.39.9 pinned by its multi-platform OCI digest in
version and digest together, then run the Workshop integration and production-image smoke suites.
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
access. Moderators can review private reports, dismiss reports, and remove submissions or comments.
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.
## Run locally
@@ -216,7 +216,7 @@ migrate(
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
values: ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "dismissed"] },
@@ -69,6 +69,10 @@ const docsConfig = await read("apps/docs/docusaurus.config.ts");
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
}
if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) {
throw new Error("Workshop submission routes must not capture the moderation page");
}
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx"));
console.log("Workshop static contracts passed");
+2 -1
View File
@@ -268,11 +268,12 @@ const report = await request("/api/collections/reports/records", {
body: JSON.stringify({
submission: submission.id,
reporter: visitor.id,
category: "other",
category: "outdated",
explanation: "Runtime moderation test",
status: "dismissed",
}),
});
if (report.category !== "outdated") throw new Error("Outdated Workshop reports must be accepted");
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
+1 -7
View File
@@ -976,7 +976,7 @@
"description": "Copy one prompt, then paste the AI's single JSON block. Homarr loads its JSX into the editor automatically.",
"request": "What should this widget do?",
"documentationUrl": "API documentation URL (optional)",
"paste": "Paste AI response",
"paste": "Paste",
"response": "AI response",
"loaded": "Widget loaded into the editor",
"invalidResponse": "Clipboard does not contain a valid v2 widget",
@@ -1010,8 +1010,6 @@
"liveActions": "Run actions against the configured API",
"liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.",
"noActions": "No actions are defined.",
"sessionActive": "Preview session active",
"simulationPending": "Actions are simulated until you run preview",
"containedFailures": "Runtime failures are contained inside this widget. Request journals never include secret values.",
"validation": {
"title": "Validation",
@@ -1046,10 +1044,6 @@
"standard": "Standard",
"wide": "Wide"
},
"theme": {
"light": "Light",
"dark": "Dark"
},
"fixture": {
"live": "Live",
"loading": "Loading",
+5
View File
@@ -8,6 +8,7 @@ import {
normalizeHttpUrl,
resolveHomarrUrlConfig,
validateWorkshopWidget,
workshopReportCategorySchema,
workshopSubmissionInputSchema,
workshopSubmissionSummarySchema,
} from "./schema";
@@ -40,6 +41,10 @@ describe("Workshop URL configuration", () => {
});
describe("Workshop widget validation", () => {
test("accepts outdated as a report category", () => {
expect(workshopReportCategorySchema.parse("outdated")).toBe("outdated");
});
test("accepts a canonical credential-free widget", () => {
expect(validateWorkshopWidget(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(true);
expect(
+8 -1
View File
@@ -73,7 +73,14 @@ export function resolveHomarrUrlConfig(input: HomarrUrlConfigInput = {}): Homarr
return { homarrWebsiteUrl, workshopApiUrl, workshopWebUrl };
}
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
export const workshopReportCategorySchema = z.enum([
"outdated",
"malicious",
"spam",
"copyright",
"inappropriate",
"other",
]);
export const workshopSubmissionTypeSchema = z.enum(["customWidget", "customCss"]);
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;