Restore Workshop and preserve Custom Widget docs

This commit is contained in:
Thomas Camlong committed 2026-07-21 11:48:51 +02:00
1 parent 2c932f1181
commit 09e653c36f
208 files changed
+9503 -3337

No files matched your search

+52 -6
View File
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
export const WORKSHOP_API_URL = "https://homarr.dev";
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
export const WORKSHOP_CSS_SCHEMA = "homarr-custom-css-v1";
export const MAX_WORKSHOP_CSS_LENGTH = 16_384;
export const MAX_WORKSHOP_CONTENT_LENGTH = 1_000_000;
export const MAX_WORKSHOP_SCREENSHOTS = 5;
export const MAX_WORKSHOP_SCREENSHOT_BYTES = 5 * 1024 * 1024;
@@ -10,6 +12,8 @@ export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
export const workshopSubmissionTypeSchema = z.enum(["customWidget", "customCss"]);
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;
const workshopFileListSchema = z
.preprocess((value) => {
@@ -26,21 +30,32 @@ export const workshopUserSchema = z.object({
id: z.string(),
displayName: z.string(),
avatarUrl: z.string().default(""),
githubUsername: z.string().default(""),
githubProfileUrl: z.string().default(""),
isAdmin: z.boolean().default(false),
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
const workshopSubmissionBaseSchema = z.object({
id: z.string(),
type: workshopSubmissionTypeSchema,
title: z.string(),
description: z.string().default(""),
widgetSchema: z.string(),
screenshots: workshopFileListSchema,
author: z.string(),
authorName: z.string(),
authorAvatarUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
score: z.number().int(),
upvotes: z.number().int(),
downvotes: z.number().int(),
commentCount: z.number().int().default(0),
reportCount: z.number().int().default(0),
revision: z.number().int().positive().default(1),
changelog: z.string().default(""),
outdated: z.boolean().default(false),
created: z.string(),
updated: z.string(),
});
@@ -52,15 +67,18 @@ export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSc
export const workshopSubmissionInputSchema = z
.object({
type: workshopSubmissionTypeSchema,
title: z.string().trim().min(3).max(100),
description: z.string().trim().max(2_000).default(""),
content: z.string().min(1).max(MAX_WORKSHOP_CONTENT_LENGTH),
changelog: z.string().trim().max(2_000).default(""),
outdated: z.boolean().default(false),
})
.superRefine((input, context) => {
const result = validateWorkshopWidget(input.content);
const result = validateWorkshopContent(input.type, input.content);
if (!result.success) context.addIssue({ code: "custom", path: ["content"], message: result.error });
});
export type WorkshopSubmissionInput = z.infer<typeof workshopSubmissionInputSchema>;
export type WorkshopSubmissionInput = z.input<typeof workshopSubmissionInputSchema>;
export const workshopScreenshotsSchema = z
.array(
@@ -87,16 +105,44 @@ export const workshopReportSchema = z.object({
submissionTitle: z.string().default("Deleted submission"),
category: workshopReportCategorySchema,
explanation: z.string(),
status: z.enum(["open", "dismissed"]).default("open"),
created: z.string(),
updated: z.string(),
});
export type WorkshopReport = z.infer<typeof workshopReportSchema>;
export type WorkshopValidationResult =
export const workshopCommentSchema = z.object({
id: z.string(),
submission: z.string(),
author: z.string(),
content: z.string().min(1).max(2_000),
created: z.string(),
updated: z.string(),
authorName: z.string().default("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubProfileUrl: z.string().default(""),
});
export type WorkshopComment = z.infer<typeof workshopCommentSchema>;
export type WorkshopWidgetValidationResult =
| { success: true; data: z.infer<typeof customWidgetImportSchema> }
| { success: false; error: string };
export type WorkshopValidationResult =
| { success: true; data: z.infer<typeof customWidgetImportSchema> | string }
| { success: false; error: string };
export function validateWorkshopWidget(content: string): WorkshopValidationResult {
export function validateWorkshopContent(type: WorkshopSubmissionType, content: string): WorkshopValidationResult {
if (type === "customCss") {
if (!content.trim()) return { success: false, error: "CSS cannot be empty" };
if (content.length > MAX_WORKSHOP_CSS_LENGTH)
return { success: false, error: `CSS must be at most ${MAX_WORKSHOP_CSS_LENGTH} characters` };
return { success: true, data: content };
}
return validateWorkshopWidget(content);
}
export function validateWorkshopWidget(content: string): WorkshopWidgetValidationResult {
try {
const parsed: unknown = JSON.parse(content);
const result = customWidgetImportSchema.safeParse(parsed);
@@ -113,12 +159,12 @@ export function validateWorkshopWidget(content: string): WorkshopValidationResul
}
}
export function workshopExportFilename(title: string) {
export function workshopExportFilename(title: string, type: WorkshopSubmissionType = "customWidget") {
const safe =
title
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/^-|-$/g, "") || "homarr-widget";
return `${safe}.json`;
return `${safe}.${type === "customCss" ? "css" : "json"}`;
}