feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
121a83040d
commit
c69e33ac8c
58 files changed
+4475
-112
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
let client: PocketBase | undefined;
|
||||
let clientUrl: string | undefined;
|
||||
|
||||
export const getPocketBase = (url: string): PocketBase => {
|
||||
if (!client || clientUrl !== url) {
|
||||
client = new PocketBase(url);
|
||||
clientUrl = url;
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
|
||||
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
|
||||
|
||||
export interface WorkshopSubmission {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
type: "css" | "widget";
|
||||
title: string;
|
||||
description: string;
|
||||
schemaVersion: string;
|
||||
content: string;
|
||||
screenshots: string[];
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
commentCount: number;
|
||||
version: number;
|
||||
changelog: string;
|
||||
author: string;
|
||||
authorName: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface WorkshopVote {
|
||||
id: string;
|
||||
submission: string;
|
||||
user: string;
|
||||
value: 1 | -1;
|
||||
}
|
||||
|
||||
export interface WorkshopComment {
|
||||
id: string;
|
||||
submission: string;
|
||||
author: string;
|
||||
content: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
expand?: { author?: { id: string; name?: string; username?: string } };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function errorMessage(caught: unknown, fallback: string) {
|
||||
return caught instanceof Error ? caught.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateSubmissionContent } from "./workshop-schema";
|
||||
|
||||
const validWidget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Example",
|
||||
url: "https://example.com/api",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
displayConfig: { type: "singleValue", jsonPath: "$.count", label: "Count", unit: "" },
|
||||
};
|
||||
|
||||
describe("validateSubmissionContent", () => {
|
||||
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
|
||||
expect(validateSubmissionContent("widget", JSON.stringify(validWidget)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an old/unknown widget schema version", () => {
|
||||
const result = validateSubmissionContent(
|
||||
"widget",
|
||||
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects widgets without a schema version", () => {
|
||||
const widgetWithoutSchema = {
|
||||
name: validWidget.name,
|
||||
url: validWidget.url,
|
||||
authType: validWidget.authType,
|
||||
method: validWidget.method,
|
||||
displayType: validWidget.displayType,
|
||||
displayConfig: validWidget.displayConfig,
|
||||
};
|
||||
|
||||
expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed widget JSON", () => {
|
||||
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts non-empty CSS and rejects empty CSS", () => {
|
||||
expect(validateSubmissionContent("css", ".card { color: red; }").success).toBe(true);
|
||||
expect(validateSubmissionContent("css", " ").success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type SubmissionType = "css" | "widget";
|
||||
|
||||
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
|
||||
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
|
||||
|
||||
export const MAX_CSS_LENGTH = 16384;
|
||||
|
||||
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
|
||||
|
||||
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
|
||||
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
const displayTypes = [
|
||||
"singleValue",
|
||||
"keyValue",
|
||||
"table",
|
||||
"statGrid",
|
||||
"progressBars",
|
||||
"statusIndicator",
|
||||
"countGrid",
|
||||
"raw",
|
||||
"actionButton",
|
||||
"customJsx",
|
||||
] as const;
|
||||
|
||||
const widgetSchema = z
|
||||
.object({
|
||||
$schema: z.literal(WIDGET_SCHEMA_VERSION),
|
||||
name: z.string().min(1).max(128),
|
||||
url: z.string().min(1),
|
||||
authType: z.enum(authTypes),
|
||||
method: z.enum(methods),
|
||||
displayType: z.enum(displayTypes),
|
||||
displayConfig: z.object({ type: z.enum(displayTypes) }),
|
||||
})
|
||||
.refine((widget) => widget.displayConfig.type === widget.displayType, {
|
||||
message: "displayConfig.type must match displayType",
|
||||
});
|
||||
|
||||
const validateWidgetContent = (raw: string): WorkshopValidationResult => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { success: false, error: "Content is not valid JSON" };
|
||||
}
|
||||
|
||||
const result = widgetSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error.issues[0]?.message ?? "Invalid custom widget JSON" };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
};
|
||||
|
||||
const validateCssContent = (raw: string): WorkshopValidationResult => {
|
||||
if (raw.trim().length === 0) {
|
||||
return { success: false, error: "CSS cannot be empty" };
|
||||
}
|
||||
if (raw.length > MAX_CSS_LENGTH) {
|
||||
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
|
||||
}
|
||||
return { success: true, data: raw };
|
||||
};
|
||||
|
||||
export const submissionValidators = {
|
||||
widget: validateWidgetContent,
|
||||
css: validateCssContent,
|
||||
} satisfies Record<SubmissionType, (raw: string) => WorkshopValidationResult>;
|
||||
|
||||
export const schemaVersionByType = {
|
||||
widget: WIDGET_SCHEMA_VERSION,
|
||||
css: CSS_SCHEMA_VERSION,
|
||||
} satisfies Record<SubmissionType, string>;
|
||||
|
||||
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
|
||||
submissionValidators[type](raw);
|
||||
Reference in new issue
Block a user