feat: unify Workshop experience
This commit is contained in:
27 files changed
+1184
-483
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"}`;
|
||||
}
|
||||
Reference in new issue
Block a user