fix(workshop): expose moderation

This commit is contained in:
Thomas Camlong committed 2026-08-10 17:37:13 +02:00
1 parent be93fb330a
commit 378bc0cd28
13 files changed
+58 -12

No files matched your search

+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 {
@@ -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 });
+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>;