Merge commit '22e955529' into feat/widget-modernization

This commit is contained in:
Thomas Camlong committed 2026-08-10 18:48:22 +02:00
commit d04eec39b6
13 files changed
+234 -61

No files matched your search

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