Polish Workshop and custom widget docs

This commit is contained in:
Thomas Camlong committed 2026-07-22 14:56:15 +02:00
1 parent 2236ec7cec
commit 47cd7dda1f
20 files changed
+733 -492

No files matched your search

+5 -5
View File
@@ -27,9 +27,10 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
- 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, reports, comments, and full content.
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content.
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
- Reports and the manual **Outdated** badge are warnings, not automatic removal. Reported and outdated entries remain installable after confirmation.
- Report details and reporter identities are visible only to Workshop administrators. Reports do not automatically remove a submission.
- The manual **Outdated** badge warns visitors that a submission may need an update.
## Comments
@@ -38,7 +39,7 @@ Comments are publicly readable. Signed-in users can post; authors can edit or de
## Submitting
1. Sign in with GitHub.
2. Click **Submit** and choose the type:
2. Click **Share yours** and choose the type:
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
@@ -62,8 +63,7 @@ You can install workshop entries without leaving Homarr:
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
manually.
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.
Custom Widget imports detect required bearer, basic, header, or query credentials. You can enter them during import or explicitly defer them; secret values are never stored in Workshop content. Missing credentials remain visible on the local Custom Widget afterward.
-11
View File
@@ -46,17 +46,6 @@ const config: Config = {
themes: ["@docusaurus/theme-mermaid"],
scripts: [
{
src: "https://widget.kapa.ai/kapa-widget.bundle.js",
"data-website-id": "1e4656f4-abeb-4343-bbae-1d8626f52378",
"data-project-name": "Homarr",
"data-project-color": "#2B2B2B",
"data-project-logo": "https://homarr.dev/img/favicon.png",
async: true,
},
],
presets: [
[
"classic",
@@ -1,19 +1,8 @@
import { useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles/Badge.css";
import "@mantine/core/styles/Button.css";
import "@mantine/core/styles/Combobox.css";
import "@mantine/core/styles/Group.css";
import "@mantine/core/styles/Input.css";
import "@mantine/core/styles/Loader.css";
import "@mantine/core/styles/Pill.css";
import "@mantine/core/styles/PillsInput.css";
import "@mantine/core/styles/Popover.css";
import "@mantine/core/styles/ScrollArea.css";
import "@mantine/core/styles/Stack.css";
import "@mantine/core/styles/Text.css";
import "@mantine/core/styles/UnstyledButton.css";
import "@mantine/core/styles.layer.css";
/* oxlint-enable import/no-unassigned-import */
import {
@@ -64,8 +53,10 @@ export function CanonicalCustomWidgetExampleClient({ id, label, example, height
}
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
const { colorMode } = useColorMode();
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<MantineProvider forceColorScheme={colorMode}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
@@ -79,21 +70,24 @@ export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProp
}
export function CustomWidgetCodeInputClient(props: CustomWidgetCodeInputProps) {
const { colorMode } = useColorMode();
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<MantineProvider forceColorScheme={colorMode}>
<CustomWidgetCodeEditor {...props} messages={editableMessages} />
</MantineProvider>
);
}
export function BundledCustomWidgetGalleryClient() {
const { colorMode } = useColorMode();
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<MantineProvider forceColorScheme={colorMode}>
<Stack gap="md">
<Select
label="Bundled widget"
@@ -127,7 +121,3 @@ export function BundledCustomWidgetGalleryClient() {
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
@@ -68,5 +68,15 @@ export function BundledCustomWidgetGallery() {
}
function CodeExampleFallback({ height }: { height?: string }) {
return <div style={{ minHeight: height ?? "220px" }} aria-busy="true" />;
return (
<div
style={{
minHeight: height ?? "220px",
border: "1px solid var(--ifm-color-emphasis-300)",
borderRadius: "var(--ifm-global-radius)",
background: "var(--ifm-background-surface-color)",
}}
aria-busy="true"
/>
);
}
@@ -114,7 +114,7 @@ export const CommentsSection = ({
<h2 className="text-sm font-medium">Comments</h2>
<div className="flex items-center gap-2">
<Input
className="flex-1"
className="h-10 flex-1"
placeholder="Write a comment…"
aria-label="Write a comment"
value={newComment}
@@ -123,7 +123,7 @@ export const CommentsSection = ({
if (event.key === "Enter") void handleAdd();
}}
/>
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
<Button className="size-10" size="icon-lg" onClick={() => void handleAdd()} aria-label="Post comment">
<IconSend size={14} />
</Button>
</div>
@@ -134,7 +134,16 @@ export const CommentsSection = ({
</p>
)}
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
{loading && (
<div className="space-y-2" aria-label="Loading comments">
{Array.from({ length: 2 }, (_, index) => (
<div key={index} className="animate-pulse rounded-lg bg-muted/30 px-3 py-3">
<div className="h-3 w-32 rounded bg-muted" />
<div className="mt-3 h-3 w-4/5 rounded bg-muted" />
</div>
))}
</div>
)}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
@@ -165,6 +174,7 @@ export const CommentsSection = ({
{canManage && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
type="button"
className="rounded p-1 hover:bg-accent"
aria-label="Edit comment"
onClick={() => {
@@ -175,6 +185,7 @@ export const CommentsSection = ({
<IconEdit size={12} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
aria-label="Delete comment"
onClick={() => void handleDelete(comment.id)}
+242 -78
View File
@@ -11,12 +11,21 @@ import {
IconCheck,
IconCopy,
IconDownload,
IconFlag,
IconInfoCircle,
IconLoader2,
IconRefresh,
IconX,
} from "@tabler/icons-react";
import type { ClientResponseError } from "pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl, signInWithGitHub } from "@site/src/lib/pocketbase";
import {
getPocketBase,
getSubmissionFileUrl,
parseWorkshopSubmission,
signInWithGitHub,
} from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
@@ -31,21 +40,22 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import { CustomWidgetCodeExample, CustomWidgetCodeInput } from "../custom-widget-code";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { cn, errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
{ Icon: IconCopy, iconClass: "" },
{ Icon: IconCheck, iconClass: "text-green-500" },
] as const;
const scoreClassBySign = { positive: "text-foreground", negative: "text-destructive", neutral: "" } as const;
@@ -71,7 +81,6 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
const [reports, setReports] = useState<Array<{ id: string; category: string; explanation: string }>>([]);
const [userVote, setUserVote] = useState<WorkshopVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
@@ -84,6 +93,14 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const [editDescription, setEditDescription] = useState("");
const [editChangelog, setEditChangelog] = useState("");
const [editContent, setEditContent] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [editPending, setEditPending] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportCategory, setReportCategory] = useState("other");
const [reportExplanation, setReportExplanation] = useState("");
const [reportPending, setReportPending] = useState(false);
const [reportError, setReportError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyFailedTimer = useRef<ReturnType<typeof setTimeout>>(null);
const voting = useRef(false);
@@ -138,17 +155,14 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const load = async () => {
try {
const [listing, record, reportRows] = await Promise.all([
const [listing, record] = await Promise.all([
pb.collection("workshop_listings").getOne<WorkshopSubmission>(submissionId),
pb.collection("submissions").getOne<WorkshopSubmission>(submissionId),
pb.collection("reports").getFullList<{ id: string; category: string; explanation: string }>({
filter: pb.filter("submission = {:id} && status = 'open'", { id: submissionId }),
sort: "-created",
}),
]);
if (cancelled) return;
setSubmission({ ...listing, ...record, content: record.content });
setReports(reportRows);
const parsed = parseWorkshopSubmission({ ...listing, ...record, content: record.content });
if (!parsed) throw new Error("This Workshop submission has an invalid record shape");
setSubmission(parsed);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
@@ -172,7 +186,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
return () => {
cancelled = true;
};
}, [pb, submissionId]);
}, [pb, submissionId, reloadKey]);
const screenshotUrls = useMemo(
() => submission?.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
@@ -248,6 +262,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
setEditDescription(submission.description);
setEditChangelog("");
setEditContent(submission.content);
setEditError(null);
setEditOpen(true);
};
@@ -255,9 +270,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (!submission) return;
const validation = validateSubmissionContent(submission.type, editContent);
if (!validation.success) {
setError(validation.error);
setEditError(validation.error);
return;
}
setEditPending(true);
setEditError(null);
try {
const updated = await pb.collection("submissions").update<WorkshopSubmission>(submission.id, {
title: editTitle.trim(),
@@ -270,7 +287,32 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
setEditOpen(false);
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Failed to update submission"));
setEditError(errorMessage(caught, "Failed to update submission"));
} finally {
setEditPending(false);
}
};
const handleReport = async () => {
if (!submission) return;
const userId = await requireUserId("report this submission");
if (!userId) return;
setReportPending(true);
setReportError(null);
try {
await pb.collection("reports").create({
submission: submission.id,
reporter: userId,
category: reportCategory,
explanation: reportExplanation.trim(),
status: "open",
});
setReportOpen(false);
setReportExplanation("");
} catch (caught) {
setReportError(errorMessage(caught, "Failed to submit report"));
} finally {
setReportPending(false);
}
};
@@ -292,6 +334,28 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (loading) return <DetailSkeleton />;
if (error && !submission) {
return (
<div className="mx-auto flex min-h-[50vh] max-w-xl flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconX size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">Submission could not be loaded</h1>
<p className="mt-1 text-sm text-muted-foreground">Check the Workshop service and try again.</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => setReloadKey((value) => value + 1)}>
<IconRefresh size={15} /> Try loading again
</Button>
<Button variant="ghost" nativeButton={false} render={<Link to="/workshop" />}>
Back to Workshop
</Button>
</div>
</div>
);
}
if (notFound || !submission) {
return (
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
@@ -308,7 +372,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
}
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const { Icon: CopyIcon, iconClass: copyIconClass } = copyState[Number(copied)];
return (
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
@@ -340,7 +404,6 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{typeLabels[submission.type]}
</Badge>
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && <Badge variant="destructive">Reported</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
<a
@@ -360,10 +423,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
type="button"
onClick={() => void handleVote(1)}
aria-label="Upvote"
aria-pressed={userVote?.value === 1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-9",
userVote?.value === 1 && "bg-primary/15 text-primary",
)}
>
@@ -375,10 +440,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{score}
</span>
<button
type="button"
onClick={() => void handleVote(-1)}
aria-label="Downvote"
aria-pressed={userVote?.value === -1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-9",
userVote?.value === -1 && "bg-primary/15 text-primary",
)}
>
@@ -392,56 +459,65 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
)}
{submission.changelog && (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Changelog</p>
<p className="text-xs font-medium text-muted-foreground">Changelog</p>
<p className="mt-1 text-sm">{submission.changelog}</p>
</div>
)}
{reports.length > 0 && (
<div className="mt-6 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm">
<p className="font-medium text-destructive">
The community has reported this submission {reports.length} time{reports.length === 1 ? "" : "s"}.
</p>
<ul className="mt-2 space-y-1 text-muted-foreground">
{reports.map((report) => (
<li key={report.id}>
<strong>{report.category}:</strong> {report.explanation}
</li>
))}
</ul>
</div>
)}
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopy()}>
{submission.type === "customWidget" && (
<Button className="h-10 sm:h-7" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download widget JSON
</Button>
)}
<Button
className="h-10 sm:h-7"
variant={submission.type === "customCss" ? "default" : "outline"}
size="sm"
onClick={() => void handleCopy()}
>
{copyFailed ? (
<>
<IconX size={14} className="text-destructive" /> Copy failed
</>
) : (
<>
<CopyIcon size={14} className={copyIconClass} /> {copyLabel}
<CopyIcon size={14} className={copyIconClass} />
{copied ? "Copied" : submission.type === "customCss" ? "Copy CSS" : "Copy widget JSON"}
</>
)}
</Button>
{submission.type === "customWidget" && (
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download
</Button>
)}
<Button className="h-10 sm:h-7" variant="ghost" size="sm" onClick={() => setReportOpen(true)}>
<IconFlag size={14} /> Report submission
</Button>
{(user?.id === submission.author || user?.isAdmin === true) && (
<Button variant="outline" size="sm" onClick={() => void handleOutdated()}>
<Button className="h-10 sm:h-7" variant="outline" size="sm" onClick={() => void handleOutdated()}>
{submission.outdated ? "Mark current" : "Mark outdated"}
</Button>
)}
{user?.id === submission.author && (
<Button variant="outline" size="sm" onClick={openEdit}>
<Button className="h-10 sm:h-7" variant="outline" size="sm" onClick={openEdit}>
Edit submission
</Button>
)}
{(user?.id === submission.author || user?.isAdmin === true) && <DeleteConfirmButton onConfirm={handleDelete} />}
</div>
<div className="mt-4 flex gap-3 rounded-lg bg-muted/40 p-4 text-sm">
<IconInfoCircle size={18} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">Install in Homarr</p>
<p className="mt-1 max-w-2xl text-muted-foreground">
{submission.type === "customWidget"
? "Download the JSON, then open Manage → Custom Widgets → Import in your Homarr instance. Homarr asks for any required credentials during import."
: "Copy the CSS, then open your board settings and paste it into Custom CSS."}{" "}
<Link to="/docs/management/workshop/" className="font-medium text-primary hover:underline">
Read the installation guide
</Link>
</p>
</div>
</div>
<div className="mt-6">
{submission.type === "customWidget" ? (
<CustomWidgetCodeExample
@@ -466,49 +542,133 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
onError={setError}
/>
</div>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog
open={reportOpen}
onOpenChange={(open) => {
if (reportPending) return;
setReportOpen(open);
if (!open) setReportError(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Report submission</DialogTitle>
<DialogDescription>
Only Workshop administrators can review report details and reporter identities.
</DialogDescription>
</DialogHeader>
{reportError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{reportError}
</div>
)}
<label htmlFor="workshop-report-category" className="grid gap-1.5 text-sm">
Category
<select
id="workshop-report-category"
className="h-10 rounded-md border border-input bg-transparent px-3"
value={reportCategory}
onChange={(event) => setReportCategory(event.target.value)}
>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label htmlFor="workshop-report-explanation" className="grid gap-1.5 text-sm">
Explanation
<Textarea
id="workshop-report-explanation"
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what should be reviewed"
maxLength={1000}
rows={5}
/>
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => setReportOpen(false)} disabled={reportPending}>
Cancel
</Button>
<Button
variant="destructive"
disabled={reportPending || reportExplanation.trim().length < 3}
onClick={() => void handleReport()}
>
{reportPending && <IconLoader2 size={14} className="animate-spin" />}
{reportPending ? "Submitting…" : "Submit report"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={editOpen}
onOpenChange={(open) => {
if (!editPending) setEditOpen(open);
}}
>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Edit submission</DialogTitle>
<DialogDescription>Saving creates a new revision and keeps the same Workshop URL.</DialogDescription>
</DialogHeader>
<label htmlFor="workshop-edit-title" className="grid gap-1.5 text-sm">
Title
<Input id="workshop-edit-title" value={editTitle} onChange={(event) => setEditTitle(event.target.value)} />
</label>
<label htmlFor="workshop-edit-description" className="grid gap-1.5 text-sm">
Description
<Textarea
id="workshop-edit-description"
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
/>
</label>
<label htmlFor="workshop-edit-changelog" className="grid gap-1.5 text-sm">
Changelog
<Textarea
id="workshop-edit-changelog"
value={editChangelog}
onChange={(event) => setEditChangelog(event.target.value)}
placeholder="What changed?"
/>
</label>
<label htmlFor="workshop-edit-content" className="grid gap-1.5 text-sm">
Content
<Textarea
{editError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{editError}
</div>
)}
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.55fr)]">
<CustomWidgetCodeInput
id="workshop-edit-content"
className="font-mono text-xs"
rows={14}
label={submission.type === "customWidget" ? "Widget JSON" : "Custom CSS"}
language={submission.type === "customWidget" ? "json" : "css"}
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onChange={setEditContent}
height="min(46vh, 460px)"
required
/>
</label>
<div className="flex flex-col gap-4">
<label htmlFor="workshop-edit-title" className="grid gap-1.5 text-sm">
Title
<Input
id="workshop-edit-title"
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
/>
</label>
<label htmlFor="workshop-edit-description" className="grid gap-1.5 text-sm">
Description
<Textarea
id="workshop-edit-description"
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
rows={5}
/>
</label>
<label htmlFor="workshop-edit-changelog" className="grid gap-1.5 text-sm">
Changelog
<Textarea
id="workshop-edit-changelog"
value={editChangelog}
onChange={(event) => setEditChangelog(event.target.value)}
placeholder="What changed?"
rows={4}
/>
</label>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setEditOpen(false)}>
<Button variant="ghost" onClick={() => setEditOpen(false)} disabled={editPending}>
Cancel
</Button>
<Button disabled={editTitle.trim().length < 3 || !editContent.trim()} onClick={() => void handleEdit()}>
Save revision
<Button
disabled={editPending || editTitle.trim().length < 3 || !editContent.trim()}
onClick={() => void handleEdit()}
>
{editPending && <IconLoader2 size={14} className="animate-spin" />}
{editPending ? "Saving…" : "Save revision"}
</Button>
</DialogFooter>
</DialogContent>
@@ -525,7 +685,11 @@ export default function MarketplaceDetailPage() {
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>
{() => <MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />}
{() => (
<WorkshopErrorBoundary>
<MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
</main>
</Layout>
@@ -1,9 +1,17 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@site/src/lib/utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
@@ -34,6 +42,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
{urls.length > 1 && (
<>
<button
type="button"
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
@@ -41,6 +50,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
<IconChevronLeft size={16} />
</button>
<button
type="button"
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
@@ -50,6 +60,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
{urls.map((_, i) => (
<button
type="button"
key={i}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
@@ -88,36 +99,42 @@ export const CodeBlock = ({ content, language }: { content: string; language: st
};
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const handleClick = () => {
if (!pending) {
setPending(true);
timer.current = setTimeout(() => setPending(false), 3000);
return;
}
setPending(false);
onConfirm();
};
const [opened, setOpened] = useState(false);
return (
<Button
variant="outline"
size="sm"
className={cn(
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
)}
onClick={handleClick}
>
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
</Button>
<>
<Button
variant="outline"
size="sm"
className="h-10 text-destructive hover:bg-destructive/10 sm:h-7"
onClick={() => setOpened(true)}
>
<IconTrash size={14} /> Delete
</Button>
<Dialog open={opened} onOpenChange={setOpened}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete submission?</DialogTitle>
<DialogDescription>
This permanently removes the Workshop listing, screenshots, votes, reports, and comments.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setOpened(false);
onConfirm();
}}
>
Delete submission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -225,14 +225,17 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
<React.Fragment key={label}>
{i > 0 && <div className={cn("h-px flex-1 transition-colors", connectorClass[Number(i <= step)])} />}
<button
type="button"
onClick={() => {
if (i < step) goTo(i);
}}
disabled={i > step}
aria-current={i === step ? "step" : undefined}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
i === step && "bg-primary text-primary-foreground shadow-sm",
i < step && "bg-primary/10 text-primary cursor-pointer hover:bg-primary/20",
i > step && "bg-muted text-muted-foreground",
i > step && "bg-muted text-muted-foreground disabled:cursor-default",
)}
>
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
@@ -331,6 +334,7 @@ const StepType = ({
<div className="grid grid-cols-2 gap-3">
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<button
type="button"
key={type}
onClick={() => onChange(type)}
aria-pressed={value === type}
@@ -556,6 +560,7 @@ const StepMedia = ({
<div key={i} className="group relative h-24 overflow-hidden rounded-xl border border-border bg-muted">
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
+161 -292
View File
@@ -1,23 +1,20 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useMemo, useState } from "react";
import {
IconAlertCircle,
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCopy,
IconDownload,
IconFlag,
IconEye,
IconLogout,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconRefresh,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase";
@@ -34,20 +31,10 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson } from "./workshop-utils";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
@@ -60,10 +47,6 @@ const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; clas
const typeBgColors: Record<SubmissionType, string> = { customCss: "bg-blue-500/5", customWidget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const emptyState = {
none: { title: "No submissions yet", hint: "Be the first to share something." },
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
@@ -73,7 +56,6 @@ const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [
{ value: "all", label: "All" },
{ value: "customWidget", label: "Widgets", dot: typeDotColors.customWidget },
{ value: "customCss", label: "CSS", dot: typeDotColors.customCss },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
@@ -98,14 +80,17 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
const submissionIds = useMemo(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]);
const sortedIds = useMemo(() => {
const q = search.toLowerCase();
const q = search.trim().toLowerCase();
return workshop.submissions
.filter((item) => {
if (typeFilter === "yours") return item.author === workshop.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => includeOutdated || !item.outdated)
.filter((item) => !q || item.title.toLowerCase().includes(q))
.filter(
(item) =>
!q || [item.title, item.description, item.authorName].some((value) => value?.toLowerCase().includes(q)),
)
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
@@ -117,108 +102,168 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
}, [workshop.submissions, sortedIds]);
const empty = emptyState[workshop.submissions.length === 0 ? "none" : "filtered"];
const initialLoadFailed = !workshop.loading && Boolean(workshop.error) && workshop.submissions.length === 0;
const availableTypeFilters = workshop.user
? [...typeFilters, { value: "yours" as const, label: "Yours" }]
: typeFilters;
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
<div className="flex flex-col gap-6 py-8 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-5 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<p className="mt-1 text-sm text-muted-foreground">Community custom widgets and CSS themes for Homarr.</p>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 self-start sm:self-auto">
{workshop.user ? (
<>
<Button onClick={() => setShowSubmit(true)}>
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={workshop.logout}>
<Button
variant="ghost"
size="sm"
className="h-10 sm:h-7"
onClick={() => {
setTypeFilter("all");
workshop.logout();
}}
>
<IconLogout size={14} /> {workshop.user?.displayName || workshop.user?.username || "Account"}
</Button>
</>
) : (
<div className="flex flex-col items-end gap-0.5">
<Button onClick={() => void workshop.login()}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Button className="h-10 sm:h-8" onClick={() => void workshop.login()}>
<IconBrandGithub size={14} /> Sign in with GitHub
</Button>
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
<p className="text-xs text-muted-foreground">Vote, comment, report, and publish</p>
</div>
)}
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2">
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md bg-muted p-0.5">
{typeFilters.map((opt) => (
{workshop.submissions.length > 0 && (
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative order-first w-full sm:order-last sm:w-60">
<IconSearch size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-11 w-full rounded-md border border-input bg-transparent pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-9"
placeholder="Search by title, description, or author"
aria-label="Search submissions"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<fieldset
className={cn(
"grid min-w-0 rounded-md border-0 bg-muted p-0.5 sm:flex",
workshop.user ? "grid-cols-4" : "grid-cols-3",
)}
aria-label="Submission type"
>
{availableTypeFilters.map((opt) => (
<button
type="button"
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
aria-pressed={typeFilter === opt.value}
className={cn(
"inline-flex min-h-10 items-center justify-center gap-1.5 rounded px-3 text-sm font-medium transition-colors sm:min-h-8 sm:text-xs",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
))}
</fieldset>
<div className="flex items-center gap-2">
<select
aria-label="Sort submissions"
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-8 sm:flex-none sm:text-xs dark:bg-input/30"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
type="button"
onClick={() => setIncludeOutdated((value) => !value)}
aria-pressed={!includeOutdated}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
"h-10 shrink-0 rounded-md border border-input px-3 text-sm transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-8 sm:text-xs",
includeOutdated ? "bg-transparent text-muted-foreground" : filterActiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
{includeOutdated ? "Hide outdated" : "Show outdated"}
</button>
))}
</div>
</div>
<select
aria-label="Sort order"
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none dark:bg-input/30"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
>
{sortOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<button
type="button"
onClick={() => setIncludeOutdated((value) => !value)}
className={cn(
"h-7 rounded-md border border-input px-2 text-xs transition-colors",
includeOutdated ? "bg-transparent text-muted-foreground" : filterActiveClass,
)}
>
{includeOutdated ? "Hide outdated" : "Show outdated"}
</button>
</div>
<div className="relative">
<IconSearch size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-7 w-48 rounded-md border border-input bg-transparent pl-8 text-xs outline-none placeholder:text-muted-foreground"
placeholder="Search…"
aria-label="Search submissions"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{workshop.error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{workshop.error}
</div>
)}
{workshop.loading && (
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 2xl:columns-4 [&>*]:mb-4">
{initialLoadFailed && (
<div className="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-border bg-card px-6 py-12 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertCircle size={22} />
</div>
<div>
<h2 className="text-base font-semibold">Workshop listings could not be loaded</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Check the Workshop service and your connection, then try again.
</p>
</div>
<Button variant="outline" onClick={() => void workshop.refresh()}>
<IconRefresh size={15} /> Try loading again
</Button>
</div>
)}
{workshop.error && workshop.submissions.length > 0 && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm">
<span className="text-destructive">Some Workshop data could not be refreshed.</span>
<Button variant="ghost" size="sm" onClick={() => void workshop.refresh()}>
<IconRefresh size={14} /> Retry
</Button>
</div>
)}
{workshop.loading && workshop.submissions.length === 0 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
))}
</div>
)}
{!workshop.loading && visible.length === 0 && (
{!workshop.loading && !workshop.error && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{workshop.submissions.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setTypeFilter("all");
setSearch("");
setIncludeOutdated(true);
}}
>
Clear filters
</Button>
)}
{workshop.user && workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
@@ -227,18 +272,14 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
)}
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 [&>*]:mb-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{visible.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
pb={workshop.pb}
userVote={workshop.votes[submission.id]?.value}
currentUserId={workshop.user?.id}
currentUserIsAdmin={workshop.user?.isAdmin === true}
onVote={workshop.vote}
onReport={workshop.report}
onDelete={workshop.deleteSubmission}
/>
))}
</div>
@@ -259,96 +300,21 @@ interface SubmissionCardProps {
submission: WorkshopSubmission;
pb: ReturnType<typeof useWorkshop>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
currentUserIsAdmin?: boolean;
onVote: (submissionId: string, value: 1 | -1) => void;
onReport: (
submissionId: string,
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => void;
onDelete: (submissionId: string) => Promise<boolean>;
}
const SubmissionCard = ({
submission,
pb,
userVote,
currentUserId,
currentUserIsAdmin,
onVote,
onReport,
onDelete,
}: SubmissionCardProps) => {
const canDelete = currentUserId === submission.author || currentUserIsAdmin;
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProps) => {
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const screenshotUrls = useMemo(
() => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
},
[],
);
const [copyFailed, setCopyFailed] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportCategory, setReportCategory] = useState("other");
const [reportExplanation, setReportExplanation] = useState("");
const loadContent = async () => {
if (submission.content) return submission.content;
const record = await pb.collection("submissions").getOne<{ content: string }>(submission.id);
return record.content;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(await loadContent());
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDownload = async () => {
try {
downloadSubmissionJson({ ...submission, content: await loadContent() });
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
return;
}
setConfirmDelete(false);
void onDelete(submission.id);
};
const hasScreenshots = screenshotUrls.length > 0;
const TypeIcon = typeIcons[submission.type];
return (
<Card className="relative transition-transform duration-150 hover:scale-[1.02]">
<Card className="relative flex h-full flex-col">
<a href={`/workshop/${submission.id}/`} className="block">
{hasScreenshots ? (
<div className="relative">
@@ -368,7 +334,7 @@ const SubmissionCard = ({
)}
</a>
<CardHeader>
<CardHeader className="flex flex-col gap-2 sm:grid">
<div className="flex items-center gap-2">
<a href={`/workshop/${submission.id}/`} className="min-w-0 hover:underline" title={submission.title}>
<CardTitle className="truncate">{submission.title}</CardTitle>
@@ -394,13 +360,15 @@ const SubmissionCard = ({
</a>{" "}
· v{submission.revision} · {formatRelativeTime(submission.created)}
</CardDescription>
<CardAction>
<CardAction className="col-start-auto row-span-1 row-start-auto self-auto justify-self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:self-start sm:justify-self-end">
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
type="button"
onClick={() => void onVote(submission.id, 1)}
aria-label="Upvote"
aria-pressed={userVote === 1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-8",
userVote === 1 && "bg-primary/15 text-primary",
)}
>
@@ -416,10 +384,12 @@ const SubmissionCard = ({
{score}
</span>
<button
type="button"
onClick={() => void onVote(submission.id, -1)}
aria-label="Downvote"
aria-pressed={userVote === -1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-8",
userVote === -1 && "bg-primary/15 text-primary",
)}
>
@@ -429,15 +399,10 @@ const SubmissionCard = ({
</CardAction>
</CardHeader>
<CardContent>
{(submission.outdated || submission.reportCount > 0) && (
<CardContent className="flex-1">
{submission.outdated && (
<div className="mb-2 flex flex-wrap gap-1.5">
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && (
<Badge variant="destructive">
{submission.reportCount} community report{submission.reportCount === 1 ? "" : "s"}
</Badge>
)}
<Badge variant="secondary">Outdated</Badge>
</div>
)}
{submission.description && (
@@ -445,115 +410,19 @@ const SubmissionCard = ({
)}
</CardContent>
<CardFooter className="flex-col gap-0 p-0">
<div className="flex w-full flex-wrap items-center gap-0.5 px-2 py-1.5">
<Button
variant="ghost"
size="xs"
onClick={() => void handleCopy()}
className="text-muted-foreground hover:text-foreground"
>
{copyFailed ? (
<>
<IconX size={13} className="text-destructive" /> Failed
</>
) : (
<>
<CopyIcon size={13} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "customWidget" && (
<Button
variant="ghost"
size="xs"
onClick={() => void handleDownload()}
className="text-muted-foreground hover:text-foreground"
>
<IconDownload size={13} /> Download
</Button>
)}
{submission.commentCount > 0 && (
<a
href={`/workshop/${submission.id}/`}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<IconMessage size={13} /> {submission.commentCount}
</a>
)}
<div className="ml-auto flex items-center gap-px">
{canDelete && (
<button
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
confirmDelete
? "bg-destructive/10 text-destructive"
: "text-muted-foreground/60 hover:text-destructive",
)}
onClick={handleDelete}
aria-label="Delete"
>
<IconTrash size={13} />
{confirmDelete && "Confirm?"}
</button>
)}
<button
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
onClick={() => setReportOpen(true)}
aria-label="Report"
>
<IconFlag size={13} />
</button>
</div>
</div>
<CardFooter className="justify-between gap-3 px-3 py-2.5">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconMessage size={14} /> {submission.commentCount} {submission.commentCount === 1 ? "comment" : "comments"}
</span>
<Button
className="h-10 sm:h-7"
size="sm"
nativeButton={false}
render={<a href={`/workshop/${submission.id}/`} aria-label={`View ${submission.title} details`} />}
>
<IconEye size={14} /> View details
</Button>
</CardFooter>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Report submission</DialogTitle>
<DialogDescription>Reports are public and help others make an informed choice.</DialogDescription>
</DialogHeader>
<label className="grid gap-1.5 text-sm">
Category
<select
className="h-9 rounded-md border border-input bg-transparent px-3"
value={reportCategory}
onChange={(event) => setReportCategory(event.target.value)}
>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<Textarea
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what is wrong…"
maxLength={1000}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setReportOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={reportExplanation.trim().length < 3}
onClick={() => {
void onReport(
submission.id,
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportExplanation.trim(),
);
setReportOpen(false);
}}
>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
@@ -574,12 +443,9 @@ const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => (
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</div>
</CardContent>
<CardFooter className="p-0">
<div className="flex w-full gap-2 px-2 py-2">
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-16 animate-pulse rounded bg-muted" />
</div>
<CardFooter className="justify-between gap-3 px-3 py-2.5">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-9 w-28 animate-pulse rounded bg-muted" />
</CardFooter>
</Card>
);
@@ -601,6 +467,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
{urls.length > 1 && (
<>
<button
type="button"
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
@@ -611,6 +478,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
<IconChevronLeft size={14} />
</button>
<button
type="button"
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
@@ -623,6 +491,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1 rounded-full bg-black/50 px-2 py-1">
{urls.map((_, i) => (
<button
type="button"
key={i}
onClick={(event) => {
stopCardNavigation(event);
@@ -0,0 +1,35 @@
import React from "react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
interface State {
failed: boolean;
}
export class WorkshopErrorBoundary extends React.Component<React.PropsWithChildren<object>, State> {
public state: State = { failed: false };
public static getDerivedStateFromError(): State {
return { failed: true };
}
public render() {
if (!this.state.failed) return this.props.children;
return (
<div className="mx-auto flex min-h-[50vh] max-w-xl flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertTriangle size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">Workshop could not be displayed</h1>
<p className="mt-1 text-sm text-muted-foreground">
Reload the page to try again. Your Homarr installation is not affected.
</p>
</div>
<Button onClick={() => window.location.reload()}>Reload Workshop</Button>
</div>
);
}
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, signInWithGitHub } from "@site/src/lib/pocketbase";
import { getPocketBase, parseWorkshopSubmission, signInWithGitHub } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
@@ -62,7 +62,13 @@ export const useWorkshop = (workshopUrl: string) => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("workshop_listings").getFullList<WorkshopSubmission>({ sort: "-created" }));
const rows = await pb.collection("workshop_listings").getFullList<WorkshopSubmission>({ sort: "-created" });
const parsed = rows
.map(parseWorkshopSubmission)
.filter((submission): submission is WorkshopSubmission => submission !== null);
setSubmissions(parsed);
if (parsed.length !== rows.length)
setError(`${rows.length - parsed.length} Workshop submission could not be displayed`);
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
+11 -4
View File
@@ -112,6 +112,12 @@
--sidebar-ring: oklch(0.723 0.014 214.4);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--ifm-font-family-base);
}
[data-theme="dark"] {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
@@ -121,18 +127,19 @@
--ifm-color-primary-lighter: #fb8282;
--ifm-color-primary-lightest: #fdc3c3;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--homarr-secondary-bg: oklch(0.218 0.008 223.9);
--homarr-secondary-bg: oklch(0.245 0.006 223.9);
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
--background: oklch(0.148 0.004 228.8);
/* Preserve Docusaurus's familiar charcoal canvas instead of a near-black app shell. */
--background: #1b1b1d;
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card: oklch(0.245 0.006 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover: oklch(0.245 0.006 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422);
+18 -24
View File
@@ -1,5 +1,7 @@
import PocketBase from "pocketbase";
import { workshopSubmissionSummarySchema, type WorkshopSubmissionSummary } from "@homarr/workshop/schema";
let client: PocketBase | undefined;
let clientUrl: string | undefined;
@@ -34,31 +36,23 @@ export const signInWithGitHub = async (pb: PocketBase) => {
return updated;
};
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "customCss" | "customWidget";
title: string;
description: string;
widgetSchema: string;
export type WorkshopSubmission = WorkshopSubmissionSummary & {
collectionId?: string;
collectionName?: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
commentCount: number;
reportCount: number;
revision: number;
changelog: string;
outdated: boolean;
author: string;
authorName: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: string;
created: string;
updated: string;
}
};
export const parseWorkshopSubmission = (value: unknown): WorkshopSubmission | null => {
const result = workshopSubmissionSummarySchema.safeParse(value);
if (!result.success) return null;
const record = value as Record<string, unknown>;
return {
...result.data,
collectionId: typeof record.collectionId === "string" ? record.collectionId : undefined,
collectionName: typeof record.collectionName === "string" ? record.collectionName : undefined,
content: typeof record.content === "string" ? record.content : "",
};
};
export interface WorkshopVote {
id: string;
+8 -4
View File
@@ -3,6 +3,9 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopApp } from "@site/src/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
@@ -11,10 +14,11 @@ export default function WorkshopPage() {
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp");
return <WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />;
}}
{() => (
<WorkshopErrorBoundary>
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
</main>
</Layout>
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New Comment on Homarr Workshop</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4">
<tr>
<td align="center" style="padding: 50px 20px">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 600px; max-width: 100%; background-color: #ffffff; border: 1px solid #000000"
>
<!-- Header -->
<tr>
<td align="center" style="background-color: #000000; padding: 36px 20px">
<a href="https://homarr.dev" target="_blank" style="text-decoration: none">
<img
src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.png"
alt="Homarr"
width="170"
style="display: block; width: 170px; height: auto; border: 0; margin: 0 auto"
/>
</a>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 45px 40px; color: #000000; font-size: 15px; line-height: 1.7">
<p style="margin: 0 0 18px 0">Hi there,</p>
<p style="margin: 0 0 18px 0">
<strong>{{commenterName}}</strong> left a comment on your submission
<strong>{{submissionTitle}}</strong>:
</p>
<blockquote
style="
margin: 0 0 24px 0;
padding: 16px 20px;
border-left: 3px solid #000000;
background-color: #f9f9f9;
color: #555555;
"
>
{{commentExcerpt}}
</blockquote>
<!-- CTA button -->
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="background-color: #000000">
<a
href="{{submissionUrl}}"
target="_blank"
style="
display: inline-block;
padding: 12px 28px;
color: #ffffff;
font-size: 14px;
font-weight: bold;
text-decoration: none;
letter-spacing: 0.5px;
"
>
VIEW THE COMMENT
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="border-top: 1px solid #000000"></td>
</tr>
<!-- Footer -->
<tr>
<td align="center" style="padding: 24px 20px; color: #000000; font-size: 12px">
<a href="https://homarr.dev" target="_blank" style="color: #000000; text-decoration: underline"
>homarr.dev</a
>
&nbsp;&middot;&nbsp; Homarr &mdash; 2026
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+9 -1
View File
@@ -40,12 +40,20 @@ onRecordAfterCreateSuccess((event) => {
const rawExcerpt = event.record.getString("content").slice(0, 280);
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
const sender = event.app.settings().meta;
const templatePath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
const template = $os
.readFile($filepath.join(templatePath, "comment-email.html"))
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
.replaceAll("{{commentExcerpt}}", escapeHtml(rawExcerpt))
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
const message = new MailerMessage({
from: { address: sender.senderAddress, name: sender.senderName },
to: [{ address: recipientEmail }],
subject: `${commenterName} commented on ${submissionTitle}`,
text: `${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
html: `<p><strong>${escapeHtml(commenterName)}</strong> commented on <strong>${escapeHtml(submissionTitle)}</strong>:</p><blockquote>${escapeHtml(rawExcerpt)}</blockquote><p><a href="${escapeHtml(submissionUrl)}">View the comment</a></p>`,
html: template,
});
event.app.newMailClient().send(message);
} catch (error) {
@@ -135,8 +135,8 @@ migrate(
const reports = new Collection({
type: "base",
name: "reports",
listRule: "",
viewRule: "",
listRule: adminRule,
viewRule: adminRule,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
deleteRule: adminRule,
@@ -1,8 +1,8 @@
.root {
margin-top: 4px;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
background: var(--mantine-color-body);
overflow: hidden;
}
@@ -15,7 +15,7 @@
.toolbar,
.footer {
padding: 7px 10px;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
background: var(--mantine-color-default-hover);
}
.toolbar {
@@ -29,8 +29,8 @@
overflow: auto;
}
.viewport :global(.cm-editor) {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
background: var(--mantine-color-body);
color: var(--mantine-color-text);
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
@@ -42,7 +42,7 @@
overflow: auto;
}
.viewport :global(.cm-gutters) {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
background: var(--mantine-color-default-hover);
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
}
.diagnosticButton {
+20 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { validateWorkshopWidget, workshopSubmissionInputSchema } from "./schema";
import { validateWorkshopWidget, workshopSubmissionInputSchema, workshopSubmissionSummarySchema } from "./schema";
describe("Workshop widget validation", () => {
test("accepts a canonical credential-free widget", () => {
@@ -55,6 +55,25 @@ describe("Workshop widget validation", () => {
).toBe(false);
});
test("normalizes PocketBase records before rendering", () => {
const result = workshopSubmissionSummarySchema.parse({
id: "jzyxskmimxn42io",
title: "Tautulli Activity",
description: "Shows active Plex streams from Tautulli",
widgetSchema: "homarr-custom-widget-v2",
screenshots: "[]",
author: "wsqylmotymtqpox",
revision: 1,
created: "2026-07-21 14:19:25.790Z",
updated: "2026-07-21 14:19:25.790Z",
});
expect(result.type).toBe("customWidget");
expect(result.screenshots).toEqual([]);
expect(result.authorName).toBe("Community member");
expect(result.upvotes).toBe(0);
});
test("rejects literal credentials in templates and icon URLs", () => {
expect(
validateWorkshopWidget(
+23 -7
View File
@@ -36,7 +36,17 @@ export const workshopUserSchema = z.object({
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
const workshopSubmissionBaseSchema = z.object({
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;
if (record.type === "customWidget" || record.type === "customCss") return value;
if (record.widgetSchema === WORKSHOP_CSS_SCHEMA) return { ...record, type: "customCss" };
if (typeof record.widgetSchema === "string" && record.widgetSchema.startsWith("homarr-custom-widget-"))
return { ...record, type: "customWidget" };
return value;
};
const workshopSubmissionBaseObjectSchema = z.object({
id: z.string(),
type: workshopSubmissionTypeSchema,
title: z.string(),
@@ -44,13 +54,13 @@ const workshopSubmissionBaseSchema = z.object({
widgetSchema: z.string(),
screenshots: workshopFileListSchema,
author: z.string(),
authorName: z.string(),
authorName: z.string().default("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
score: z.number().int(),
upvotes: z.number().int(),
downvotes: z.number().int(),
score: z.number().int().default(0),
upvotes: z.number().int().default(0),
downvotes: z.number().int().default(0),
commentCount: z.number().int().default(0),
reportCount: z.number().int().default(0),
revision: z.number().int().positive().default(1),
@@ -60,9 +70,15 @@ const workshopSubmissionBaseSchema = z.object({
updated: z.string(),
});
export const workshopSubmissionSummarySchema = workshopSubmissionBaseSchema;
export const workshopSubmissionSummarySchema = z.preprocess(
normalizeWorkshopSubmissionRecord,
workshopSubmissionBaseObjectSchema,
);
export type WorkshopSubmissionSummary = z.infer<typeof workshopSubmissionSummarySchema>;
export const workshopSubmissionDetailSchema = workshopSubmissionBaseSchema.extend({ content: z.string() });
export const workshopSubmissionDetailSchema = z.preprocess(
normalizeWorkshopSubmissionRecord,
workshopSubmissionBaseObjectSchema.extend({ content: z.string() }),
);
export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSchema>;
export const workshopSubmissionInputSchema = z