feat: unify Workshop experience

This commit is contained in:
Thomas Camlong committed 2026-07-14 01:21:53 +02:00
1 parent 066090d99e
commit 9f979f683a
27 files changed
+1184 -483

No files matched your search

+9 -2
View File
@@ -15,6 +15,7 @@ import type {
import {
WORKSHOP_API_URL,
WORKSHOP_REQUEST_TIMEOUT_MS,
WorkshopError,
schemaVersionForType,
workshopAccountStateSchema,
@@ -58,6 +59,9 @@ const errorCodeForStatus = (status: number) => {
return "unknown" as const;
};
const workshopRequestSignal = (signal?: AbortSignal) =>
AbortSignal.any(signal ? [signal, AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)] : [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)]);
const asWorkshopError = (error: unknown, fallback: string) => {
if (error instanceof WorkshopError) return error;
if (error instanceof ClientResponseError) {
@@ -75,6 +79,9 @@ const asWorkshopError = (error: unknown, fallback: string) => {
if (error instanceof TypeError) {
return new WorkshopError("unavailable", fallback);
}
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
return new WorkshopError("unavailable", `${fallback}. The request timed out.`);
}
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
};
@@ -150,7 +157,7 @@ export class WorkshopClient {
.getList(options.page ?? 1, options.perPage ?? 24, {
filter: filters.join(" && "),
sort: options.sort === "newest" ? "-created" : "-score,-created",
signal: options.signal,
signal: workshopRequestSignal(options.signal),
});
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
} catch (error) {
@@ -161,7 +168,7 @@ export class WorkshopClient {
async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
try {
return workshopSubmissionDetailSchema.parse(
await this.pocketBase.collection("submissions").getOne(id, { signal }),
await this.pocketBase.collection("submissions").getOne(id, { signal: workshopRequestSignal(signal) }),
);
} catch (error) {
throw asWorkshopError(error, "Submission not found");
+2
View File
@@ -57,6 +57,8 @@ describe("Workshop contracts", () => {
expect(schemaVersionForType("widget")).toBe(WIDGET_SCHEMA_VERSION);
expect(schemaVersionForType("css")).toBe(CSS_SCHEMA_VERSION);
expect(workshopExportFilename("My / Theme!", "css")).toBe("my-theme.css");
expect(workshopExportFilename("---My---Widget---", "widget")).toBe("my-widget.json");
expect(workshopExportFilename("-".repeat(100_000), "css")).toBe("homarr-workshop.css");
});
it("validates screenshot count, MIME type, and size", () => {
+18 -7
View File
@@ -1,7 +1,7 @@
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { z } from "zod";
export const WORKSHOP_API_URL = "https://workshop.homarr.dev";
export const WORKSHOP_API_URL = "https://homarr.dev";
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
@@ -9,6 +9,7 @@ export const MAX_CSS_LENGTH = 16_384;
export const MAX_CONTENT_LENGTH = 1_000_000;
export const MAX_SCREENSHOTS = 5;
export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export const workshopSubmissionTypeSchema = z.enum(["widget", "css"]);
@@ -165,11 +166,21 @@ export const schemaVersionForType = (type: WorkshopSubmissionType) =>
type === "widget" ? WIDGET_SCHEMA_VERSION : CSS_SCHEMA_VERSION;
export function workshopExportFilename(title: string, type: WorkshopSubmissionType) {
const safeTitle =
title
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "homarr-workshop";
let safeTitle = "";
let separatorPending = false;
for (const character of title.trim().toLowerCase()) {
const isLetter = character >= "a" && character <= "z";
const isNumber = character >= "0" && character <= "9";
if (isLetter || isNumber || character === "_") {
if (separatorPending && safeTitle.length > 0) safeTitle += "-";
safeTitle += character;
separatorPending = false;
} else {
separatorPending = safeTitle.length > 0;
}
}
safeTitle ||= "homarr-workshop";
return `${safeTitle}.${type === "widget" ? "json" : "css"}`;
}