feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,901 @@
|
||||
import PocketBase, { ClientResponseError } from "pocketbase";
|
||||
import type { RecordService } from "pocketbase";
|
||||
import {
|
||||
keepPreviousData,
|
||||
mutationOptions,
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import type {
|
||||
WorkshopComment,
|
||||
WorkshopAssistantUsage,
|
||||
WorkshopReport,
|
||||
WorkshopReportSummary,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionInput,
|
||||
WorkshopSubmissionSummary,
|
||||
WorkshopSubmissionType,
|
||||
WorkshopUser,
|
||||
WorkshopVote,
|
||||
} from "./schema";
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
MAX_WORKSHOP_SCREENSHOTS,
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WORKSHOP_SCHEMA_BY_TYPE,
|
||||
workshopCommentSchema,
|
||||
workshopAssistantUsageSchema,
|
||||
workshopReportSchema,
|
||||
workshopReportSummarySchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
workshopSubmissionInputSchema,
|
||||
workshopScreenshotsSchema,
|
||||
workshopUserSchema,
|
||||
workshopVoteSchema,
|
||||
} from "./schema";
|
||||
|
||||
export interface WorkshopListOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
sort?: "top" | "newest" | "recent" | "discussed";
|
||||
search?: string;
|
||||
author?: string;
|
||||
type?: WorkshopSubmissionType | "all";
|
||||
includeOutdated?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WorkshopPage<T> {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface WorkshopScreenshotChanges {
|
||||
additions?: File[];
|
||||
removals?: string[];
|
||||
}
|
||||
|
||||
interface WorkshopBaseRecord {
|
||||
[key: string]: unknown;
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
}
|
||||
|
||||
export interface WorkshopUserRecord extends WorkshopBaseRecord {
|
||||
email: string;
|
||||
emailVisibility: boolean;
|
||||
verified: boolean;
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface WorkshopSubmissionRecord extends WorkshopBaseRecord {
|
||||
type: WorkshopSubmissionType;
|
||||
title: string;
|
||||
description: string;
|
||||
widgetSchema: string;
|
||||
content: string;
|
||||
revision: number;
|
||||
changelog: string;
|
||||
outdated: boolean;
|
||||
screenshots: string[];
|
||||
author: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
expand?: { author?: WorkshopUserRecord };
|
||||
}
|
||||
|
||||
export interface WorkshopListingRecord extends Omit<WorkshopSubmissionRecord, "content" | "screenshots"> {
|
||||
screenshots: string[] | string;
|
||||
authorName: string;
|
||||
score: number;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
commentCount: number;
|
||||
reportCount: number;
|
||||
}
|
||||
|
||||
export interface WorkshopVoteRecord extends WorkshopBaseRecord {
|
||||
submission: string;
|
||||
user: string;
|
||||
value: 1 | -1;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface WorkshopReportRecord extends WorkshopBaseRecord {
|
||||
submission: string;
|
||||
reporter: string;
|
||||
category: WorkshopReport["category"];
|
||||
explanation: string;
|
||||
status: WorkshopReport["status"];
|
||||
created: string;
|
||||
updated: string;
|
||||
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;
|
||||
content: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
expand?: { author?: WorkshopUserRecord };
|
||||
}
|
||||
|
||||
export interface TypedWorkshopPocketBase extends PocketBase {
|
||||
collection(idOrName: "users"): RecordService<WorkshopUserRecord>;
|
||||
collection(idOrName: "submissions"): RecordService<WorkshopSubmissionRecord>;
|
||||
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;
|
||||
}
|
||||
|
||||
const requestSignal = (signal?: AbortSignal) =>
|
||||
AbortSignal.any(
|
||||
signal
|
||||
? [signal, AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)]
|
||||
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
|
||||
);
|
||||
|
||||
const timestamp = (value: string) => (Number.isNaN(Date.parse(value)) ? 0 : Date.parse(value));
|
||||
|
||||
function sortListings(items: WorkshopSubmissionSummary[], sort: WorkshopListOptions["sort"]) {
|
||||
return items.toSorted((left, right) => {
|
||||
if (sort === "newest") return timestamp(right.created) - timestamp(left.created);
|
||||
if (sort === "recent") return timestamp(right.updated) - timestamp(left.updated);
|
||||
if (sort === "discussed")
|
||||
return right.commentCount - left.commentCount || timestamp(right.created) - timestamp(left.created);
|
||||
return right.score - left.score || timestamp(right.created) - timestamp(left.created);
|
||||
});
|
||||
}
|
||||
|
||||
function filterListings(items: WorkshopSubmissionSummary[], options: WorkshopListOptions) {
|
||||
const search = options.search?.trim().toLocaleLowerCase();
|
||||
return sortListings(
|
||||
items.filter((item) => {
|
||||
if (options.type && options.type !== "all" && item.type !== options.type) return false;
|
||||
if (options.author && item.author !== options.author) return false;
|
||||
if (options.includeOutdated === false && item.outdated) return false;
|
||||
if (search && !`${item.title}\n${item.description}\n${item.authorName}`.toLocaleLowerCase().includes(search))
|
||||
return false;
|
||||
return true;
|
||||
}),
|
||||
options.sort,
|
||||
);
|
||||
}
|
||||
|
||||
const listingSort = (sort: WorkshopListOptions["sort"]) => {
|
||||
if (sort === "newest") return "-created";
|
||||
if (sort === "recent") return "-updated";
|
||||
if (sort === "discussed") return "-commentCount,-created";
|
||||
return "-score,-created";
|
||||
};
|
||||
|
||||
function listingFilter(pocketBase: TypedWorkshopPocketBase, options: WorkshopListOptions) {
|
||||
const filters: string[] = [];
|
||||
if (options.type && options.type !== "all") filters.push(pocketBase.filter("type = {:type}", { type: options.type }));
|
||||
if (options.search?.trim()) {
|
||||
filters.push(
|
||||
pocketBase.filter("(title ~ {:search} || description ~ {:search} || authorName ~ {:search})", {
|
||||
search: options.search.trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options.author) filters.push(pocketBase.filter("author = {:author}", { author: options.author }));
|
||||
if (options.includeOutdated === false) filters.push("outdated = false");
|
||||
return filters.join(" && ");
|
||||
}
|
||||
|
||||
const isViewCompatibilityError = (error: unknown) =>
|
||||
error instanceof ClientResponseError && (error.status === 400 || error.status === 404);
|
||||
|
||||
const enrichAuthor = (row: unknown) => {
|
||||
if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
|
||||
const record = row as Record<string, unknown>;
|
||||
const expand = record.expand;
|
||||
const author =
|
||||
typeof expand === "object" && expand !== null && !Array.isArray(expand)
|
||||
? (expand as Record<string, unknown>).author
|
||||
: undefined;
|
||||
const user =
|
||||
typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record<string, unknown>) : {};
|
||||
return {
|
||||
...record,
|
||||
authorName: record.authorName || user.name,
|
||||
};
|
||||
};
|
||||
|
||||
const parseListings = (rows: unknown[]) =>
|
||||
rows.flatMap((row) => {
|
||||
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row));
|
||||
return result.success ? [result.data] : [];
|
||||
});
|
||||
|
||||
function workshopError(error: unknown, fallback: string) {
|
||||
const rawMessage = error instanceof Error ? error.message : String(error ?? "");
|
||||
if (/popup.*block|blocked.*popup/iu.test(rawMessage))
|
||||
return new Error("GitHub sign-in popup was blocked. Allow popups for this site and try again.", { cause: error });
|
||||
if (/cancel|closed.*popup|popup.*closed/iu.test(rawMessage))
|
||||
return new Error("GitHub sign-in was cancelled before it completed.", { cause: error });
|
||||
if (
|
||||
/provider|oauth|redirect|callback|client.?id/iu.test(rawMessage) &&
|
||||
/invalid|missing|not found|failed/iu.test(rawMessage)
|
||||
)
|
||||
return new Error("GitHub sign-in is not configured correctly on the Workshop server.", { cause: error });
|
||||
if (
|
||||
(error instanceof ClientResponseError && error.status === 0) ||
|
||||
/networkerror|failed to fetch|fetch failed|load failed|connection (?:refused|reset)|econn(?:refused|reset)|offline/iu.test(
|
||||
rawMessage,
|
||||
)
|
||||
)
|
||||
return new Error("Homarr Workshop seems to be unavailable right now. Try again in a moment.", { cause: error });
|
||||
if (error instanceof ClientResponseError) {
|
||||
const sdkMessage = error.data?.message || error.message;
|
||||
const originalMessage =
|
||||
error.originalError instanceof Error ? error.originalError.message : String(error.originalError?.message ?? "");
|
||||
const message = !sdkMessage || sdkMessage === "Something went wrong." ? originalMessage || fallback : sdkMessage;
|
||||
return Object.assign(new Error(message, { cause: error }), { status: error.status });
|
||||
}
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError"))
|
||||
return new Error(`${fallback}. The request timed out.`, { cause: error });
|
||||
return error instanceof Error ? error : new Error(fallback);
|
||||
}
|
||||
|
||||
export class WorkshopBackend {
|
||||
public readonly pocketBase: TypedWorkshopPocketBase;
|
||||
|
||||
public constructor(public readonly baseUrl = WORKSHOP_API_URL) {
|
||||
this.pocketBase = new PocketBase(baseUrl.replace(/\/$/u, "")) as TypedWorkshopPocketBase;
|
||||
this.pocketBase.autoCancellation(false);
|
||||
}
|
||||
|
||||
public get currentUser(): WorkshopUser | null {
|
||||
if (!this.pocketBase.authStore.isValid) return null;
|
||||
const record = this.pocketBase.authStore.record;
|
||||
if (!record) return null;
|
||||
const parsed = workshopUserSchema.safeParse({
|
||||
id: record.id,
|
||||
name: record.name || "",
|
||||
isAdmin: record.isAdmin === true,
|
||||
});
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
public get authToken(): string | null {
|
||||
return this.pocketBase.authStore.isValid && this.currentUser ? this.pocketBase.authStore.token : null;
|
||||
}
|
||||
|
||||
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
|
||||
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
|
||||
}
|
||||
|
||||
public async refreshAuth() {
|
||||
if (!this.pocketBase.authStore.isValid) {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await this.pocketBase.collection("users").authRefresh();
|
||||
return this.currentUser;
|
||||
} catch (error) {
|
||||
if (error instanceof ClientResponseError && (error.status === 401 || error.status === 403)) {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
}
|
||||
return this.currentUser;
|
||||
}
|
||||
}
|
||||
|
||||
public async signInWithGitHub() {
|
||||
try {
|
||||
const auth = await this.pocketBase.collection("users").authWithOAuth2({
|
||||
provider: "github",
|
||||
});
|
||||
this.pocketBase.authStore.save(auth.token, auth.record);
|
||||
const user = this.currentUser;
|
||||
if (!user) throw new Error("The Workshop returned an invalid user session.");
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw workshopError(error, "GitHub sign-in failed");
|
||||
}
|
||||
}
|
||||
|
||||
public signOut() {
|
||||
this.pocketBase.authStore.clear();
|
||||
}
|
||||
|
||||
public async getAssistantUsage(signal?: AbortSignal): Promise<WorkshopAssistantUsage> {
|
||||
try {
|
||||
const value = await this.pocketBase.send("/api/ai/usage", {
|
||||
method: "GET",
|
||||
signal: requestSignal(signal),
|
||||
requestKey: null,
|
||||
});
|
||||
return workshopAssistantUsageSchema.parse(value);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load the Homarr provider allowance");
|
||||
}
|
||||
}
|
||||
|
||||
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
|
||||
try {
|
||||
const page = options.page ?? 1;
|
||||
const perPage = options.perPage ?? 24;
|
||||
try {
|
||||
const result = await this.pocketBase.collection("workshop_listings").getList(page, perPage, {
|
||||
filter: listingFilter(this.pocketBase, options),
|
||||
sort: listingSort(options.sort),
|
||||
signal: requestSignal(options.signal),
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
items: parseListings(result.items),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isViewCompatibilityError(error)) throw error;
|
||||
const items = filterListings(await this.loadListingsFallback(options.signal), options);
|
||||
return {
|
||||
page,
|
||||
perPage,
|
||||
totalItems: items.length,
|
||||
totalPages: Math.ceil(items.length / perPage),
|
||||
items: items.slice((page - 1) * perPage, page * perPage),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load Workshop submissions");
|
||||
}
|
||||
}
|
||||
|
||||
public async listAll(options: Omit<WorkshopListOptions, "page" | "perPage"> = {}) {
|
||||
try {
|
||||
try {
|
||||
const rows = await this.pocketBase.collection("workshop_listings").getFullList({
|
||||
batch: 200,
|
||||
filter: listingFilter(this.pocketBase, options),
|
||||
sort: listingSort(options.sort),
|
||||
signal: requestSignal(options.signal),
|
||||
});
|
||||
return parseListings(rows);
|
||||
} catch (error) {
|
||||
if (!isViewCompatibilityError(error)) throw error;
|
||||
return filterListings(await this.loadListingsFallback(options.signal), options);
|
||||
}
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load Workshop submissions");
|
||||
}
|
||||
}
|
||||
|
||||
private async loadListingsFallback(signal?: AbortSignal) {
|
||||
const options = { batch: 200, signal: requestSignal(signal) };
|
||||
const rows = await this.pocketBase
|
||||
.collection("workshop_listings")
|
||||
.getFullList(options)
|
||||
.catch((error: unknown) => {
|
||||
if (isViewCompatibilityError(error))
|
||||
return this.pocketBase.collection("submissions").getFullList({ ...options, expand: "author" });
|
||||
throw error;
|
||||
});
|
||||
return parseListings(rows);
|
||||
}
|
||||
|
||||
public async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
|
||||
try {
|
||||
const submission = await this.pocketBase
|
||||
.collection("submissions")
|
||||
.getOne(id, { signal: requestSignal(signal), expand: "author" });
|
||||
const listing = await this.pocketBase
|
||||
.collection("workshop_listings")
|
||||
.getOne(id, { signal: requestSignal(signal) })
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ClientResponseError && (error.status === 400 || error.status === 404)) return {};
|
||||
throw error;
|
||||
});
|
||||
return workshopSubmissionDetailSchema.parse(
|
||||
enrichAuthor({ ...submission, ...listing, expand: submission.expand, content: submission.content }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Submission not found");
|
||||
}
|
||||
}
|
||||
|
||||
public fileUrl(submissionId: string, filename: string, thumb?: string) {
|
||||
return `${this.baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}${thumb ? `?thumb=${encodeURIComponent(thumb)}` : ""}`;
|
||||
}
|
||||
|
||||
public async create(input: WorkshopSubmissionInput, screenshots: File[] = []) {
|
||||
const parsed = workshopSubmissionInputSchema.parse(input);
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, typeof value === "string" ? value : String(value)));
|
||||
data.set("author", this.currentUser?.id ?? "");
|
||||
data.set("widgetSchema", WORKSHOP_SCHEMA_BY_TYPE[parsed.type]);
|
||||
screenshots.forEach((file) => data.append("screenshots", file));
|
||||
try {
|
||||
const result = await this.pocketBase.collection("submissions").create(data);
|
||||
return this.get(result.id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to publish submission");
|
||||
}
|
||||
}
|
||||
|
||||
public async update(id: string, input: WorkshopSubmissionInput, screenshotChanges: WorkshopScreenshotChanges = {}) {
|
||||
const parsed = workshopSubmissionInputSchema.parse(input);
|
||||
const additions = screenshotChanges.additions ?? [];
|
||||
const removals = screenshotChanges.removals ?? [];
|
||||
workshopScreenshotsSchema.parse(additions);
|
||||
const current = await this.get(id);
|
||||
const removalSet = new Set(removals);
|
||||
const retainedCount = current.screenshots.filter((filename) => !removalSet.has(filename)).length;
|
||||
if (retainedCount + additions.length > MAX_WORKSHOP_SCREENSHOTS)
|
||||
throw new Error(`A submission can have up to ${MAX_WORKSHOP_SCREENSHOTS} screenshots`);
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
...parsed,
|
||||
expectedRevision: current.revision,
|
||||
};
|
||||
if (additions.length > 0) data["screenshots+"] = additions;
|
||||
if (removals.length > 0) data["screenshots-"] = removals;
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").update(id, data);
|
||||
return this.get(id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to update submission");
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(id: string) {
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").delete(id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to delete submission");
|
||||
}
|
||||
}
|
||||
|
||||
public async vote(submission: string, value: 1 | -1): Promise<WorkshopVote | null> {
|
||||
const user = this.currentUser?.id ?? "";
|
||||
const filter = this.pocketBase.filter("submission = {:submission} && user = {:user}", { submission, user });
|
||||
try {
|
||||
const existing = await this.pocketBase
|
||||
.collection("votes")
|
||||
.getFirstListItem(filter)
|
||||
.catch((error: unknown) =>
|
||||
error instanceof ClientResponseError && error.status === 404 ? null : Promise.reject(error),
|
||||
);
|
||||
if (existing?.value === value) {
|
||||
await this.pocketBase.collection("votes").delete(existing.id);
|
||||
return null;
|
||||
}
|
||||
const record = existing
|
||||
? await this.pocketBase.collection("votes").update(existing.id, { value })
|
||||
: await this.pocketBase.collection("votes").create({ submission, user, value });
|
||||
return workshopVoteSchema.parse(record);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to save vote");
|
||||
}
|
||||
}
|
||||
|
||||
public async listVotesForCurrentUser(): Promise<WorkshopVote[]> {
|
||||
const user = this.currentUser?.id;
|
||||
if (!user) return [];
|
||||
try {
|
||||
const rows = await this.pocketBase.collection("votes").getFullList({
|
||||
filter: this.pocketBase.filter("user = {:user}", { user }),
|
||||
});
|
||||
return rows.map((row) => workshopVoteSchema.parse(row));
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load votes");
|
||||
}
|
||||
}
|
||||
|
||||
public async report(submission: string, category: WorkshopReport["category"], explanation: string) {
|
||||
try {
|
||||
return workshopReportSchema.parse(
|
||||
await this.pocketBase.collection("reports").create({
|
||||
submission,
|
||||
reporter: this.currentUser?.id ?? "",
|
||||
category,
|
||||
explanation: explanation.trim(),
|
||||
status: "open",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to report submission");
|
||||
}
|
||||
}
|
||||
|
||||
public async listReports(): Promise<WorkshopReport[]> {
|
||||
try {
|
||||
const items = await this.pocketBase
|
||||
.collection("reports")
|
||||
.getFullList({ batch: 200, filter: "status = 'open'", sort: "-created", expand: "reporter,submission" });
|
||||
return items.map((item) =>
|
||||
workshopReportSchema.parse({
|
||||
...item,
|
||||
reporterName: item.expand?.reporter?.name,
|
||||
submissionTitle: item.expand?.submission?.title,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load reports");
|
||||
}
|
||||
}
|
||||
|
||||
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" });
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to dismiss report");
|
||||
}
|
||||
}
|
||||
|
||||
public async toggleOutdated(id: string, outdated: boolean) {
|
||||
try {
|
||||
const current = await this.get(id);
|
||||
await this.pocketBase.collection("submissions").update(id, { outdated, expectedRevision: current.revision });
|
||||
return this.get(id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to update submission status");
|
||||
}
|
||||
}
|
||||
|
||||
public async listComments(submission: string): Promise<WorkshopComment[]> {
|
||||
try {
|
||||
const rows = await this.pocketBase.collection("comments").getFullList({
|
||||
filter: this.pocketBase.filter("submission = {:submission}", { submission }),
|
||||
sort: "created",
|
||||
expand: "author",
|
||||
});
|
||||
return rows.map((row) =>
|
||||
workshopCommentSchema.parse({
|
||||
...row,
|
||||
authorName: row.expand?.author?.name,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load comments");
|
||||
}
|
||||
}
|
||||
|
||||
public async createComment(submission: string, content: string): Promise<WorkshopComment> {
|
||||
try {
|
||||
const row = await this.pocketBase
|
||||
.collection("comments")
|
||||
.create({ submission, author: this.currentUser?.id ?? "", content: content.trim() }, { expand: "author" });
|
||||
return workshopCommentSchema.parse({
|
||||
...row,
|
||||
authorName: row.expand?.author?.name,
|
||||
});
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to post comment");
|
||||
}
|
||||
}
|
||||
|
||||
public async updateComment(id: string, content: string): Promise<WorkshopComment> {
|
||||
try {
|
||||
const row = await this.pocketBase
|
||||
.collection("comments")
|
||||
.update(id, { content: content.trim() }, { expand: "author" });
|
||||
return workshopCommentSchema.parse({
|
||||
...row,
|
||||
authorName: row.expand?.author?.name,
|
||||
});
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to update comment");
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteComment(id: string) {
|
||||
try {
|
||||
await this.pocketBase.collection("comments").delete(id);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to delete comment");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listKeyOptions = (options: WorkshopListOptions) => ({
|
||||
page: options.page,
|
||||
perPage: options.perPage,
|
||||
sort: options.sort,
|
||||
search: options.search,
|
||||
author: options.author,
|
||||
type: options.type,
|
||||
includeOutdated: options.includeOutdated,
|
||||
});
|
||||
|
||||
export const workshopQueryKeys = {
|
||||
all: ["workshop"] as const,
|
||||
lists: (baseUrl?: string) => [...workshopQueryKeys.all, "list", baseUrl] as const,
|
||||
list: (backend: WorkshopBackend, options: WorkshopListOptions) =>
|
||||
[...workshopQueryKeys.lists(backend.baseUrl), listKeyOptions(options)] as const,
|
||||
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,
|
||||
userVotes: (backend: WorkshopBackend) =>
|
||||
[...workshopQueryKeys.all, "user-votes", backend.baseUrl, backend.currentUser?.id ?? "anonymous"] as const,
|
||||
};
|
||||
|
||||
export const workshopListQueryOptions = (backend: WorkshopBackend, options: WorkshopListOptions = {}) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.list(backend, options),
|
||||
queryFn: ({ signal }) => backend.list({ ...options, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
export const workshopSubmissionQueryOptions = (backend: WorkshopBackend, id: string) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.detail(backend, id),
|
||||
queryFn: ({ signal }) => backend.get(id, signal),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
/** The votes the signed-in user already cast, so controls can render their own choice. */
|
||||
export const workshopUserVotesQueryOptions = (backend: WorkshopBackend) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.userVotes(backend),
|
||||
queryFn: () => backend.listVotesForCurrentUser(),
|
||||
enabled: backend.currentUser !== null,
|
||||
});
|
||||
|
||||
export const workshopCommentsQueryOptions = (backend: WorkshopBackend, submission: string) =>
|
||||
queryOptions({
|
||||
queryKey: workshopQueryKeys.comments(backend, submission),
|
||||
queryFn: () => backend.listComments(submission),
|
||||
enabled: submission.length > 0,
|
||||
});
|
||||
|
||||
export const workshopCreateMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({ input, screenshots = [] }: { input: WorkshopSubmissionInput; screenshots?: File[] }) =>
|
||||
backend.create(input, screenshots),
|
||||
});
|
||||
|
||||
export const workshopUpdateMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({
|
||||
id,
|
||||
input,
|
||||
screenshotChanges,
|
||||
}: {
|
||||
id: string;
|
||||
input: WorkshopSubmissionInput;
|
||||
screenshotChanges?: WorkshopScreenshotChanges;
|
||||
}) => backend.update(id, input, screenshotChanges),
|
||||
});
|
||||
|
||||
export const workshopDeleteMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({ mutationFn: (id: string) => backend.delete(id) });
|
||||
|
||||
export const workshopVoteMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({ submission, value }: { submission: string; value: 1 | -1 }) => backend.vote(submission, value),
|
||||
});
|
||||
|
||||
export const workshopReportMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({
|
||||
submission,
|
||||
category,
|
||||
explanation,
|
||||
}: Pick<WorkshopReport, "submission" | "category" | "explanation">) =>
|
||||
backend.report(submission, category, explanation),
|
||||
});
|
||||
|
||||
export const workshopDismissReportMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({ mutationFn: (id: string) => backend.dismissReport(id) });
|
||||
|
||||
export const workshopToggleOutdatedMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({ id, outdated }: { id: string; outdated: boolean }) => backend.toggleOutdated(id, outdated),
|
||||
});
|
||||
|
||||
export const workshopCreateCommentMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({ submission, content }: { submission: string; content: string }) =>
|
||||
backend.createComment(submission, content),
|
||||
});
|
||||
|
||||
export const workshopUpdateCommentMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) => backend.updateComment(id, content),
|
||||
});
|
||||
|
||||
export const workshopDeleteCommentMutationOptions = (backend: WorkshopBackend) =>
|
||||
mutationOptions({
|
||||
mutationFn: ({ id }: { id: string; submission: string }) => backend.deleteComment(id),
|
||||
});
|
||||
|
||||
export function useWorkshopQuery(backend: WorkshopBackend, options: WorkshopListOptions = {}) {
|
||||
return useQuery(workshopListQueryOptions(backend, options));
|
||||
}
|
||||
|
||||
export function useWorkshopSubmissionQuery(backend: WorkshopBackend, id: string) {
|
||||
return useQuery(workshopSubmissionQueryOptions(backend, id));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export function useWorkshopUserVotesQuery(backend: WorkshopBackend) {
|
||||
return useQuery(workshopUserVotesQueryOptions(backend));
|
||||
}
|
||||
|
||||
function useWorkshopInvalidation(backend: WorkshopBackend) {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
lists: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.lists(backend.baseUrl) }),
|
||||
details: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.details(backend.baseUrl) }),
|
||||
detail: (id: string) => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
|
||||
userVotes: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.userVotes(backend) }),
|
||||
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) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkshopCreateMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({ ...workshopCreateMutationOptions(backend), onSuccess: invalidation.lists });
|
||||
}
|
||||
|
||||
export function useWorkshopUpdateMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopUpdateMutationOptions(backend),
|
||||
onSuccess: async (submission) => Promise.all([invalidation.lists(), invalidation.detail(submission.id)]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopDeleteMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopDeleteMutationOptions(backend),
|
||||
onSuccess: async (_result, id) => {
|
||||
invalidation.removeDetail(id);
|
||||
await invalidation.lists();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopVoteMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopVoteMutationOptions(backend),
|
||||
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.userVotes()]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopReportMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopReportMutationOptions(backend),
|
||||
onSuccess: async (_report, input) =>
|
||||
Promise.all([
|
||||
invalidation.lists(),
|
||||
invalidation.details(),
|
||||
invalidation.reports(),
|
||||
invalidation.reportSummaries(input.submission),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopDismissReportMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopDismissReportMutationOptions(backend),
|
||||
onSuccess: async () => Promise.all([invalidation.reports(), invalidation.lists(), invalidation.details()]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopToggleOutdatedMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopToggleOutdatedMutationOptions(backend),
|
||||
onSuccess: async (submission) => Promise.all([invalidation.lists(), invalidation.detail(submission.id)]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopCreateCommentMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopCreateCommentMutationOptions(backend),
|
||||
onSuccess: async (_comment, input) =>
|
||||
Promise.all([invalidation.comments(input.submission), invalidation.lists(), invalidation.details()]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopUpdateCommentMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopUpdateCommentMutationOptions(backend),
|
||||
onSuccess: (comment) => invalidation.comments(comment.submission),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopDeleteCommentMutation(backend: WorkshopBackend) {
|
||||
const invalidation = useWorkshopInvalidation(backend);
|
||||
return useMutation({
|
||||
...workshopDeleteCommentMutationOptions(backend),
|
||||
onSuccess: async (_result, input) =>
|
||||
Promise.all([invalidation.comments(input.submission), invalidation.lists(), invalidation.details()]),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopWidgetImportMutation(
|
||||
install: ((widget: HomarrCustomWidgetV2) => Promise<void>) | undefined,
|
||||
onSuccess?: () => void,
|
||||
) {
|
||||
return useMutation({ mutationFn: async (widget: HomarrCustomWidgetV2) => install?.(widget), onSuccess });
|
||||
}
|
||||
|
||||
export function useWorkshopCssImportMutation(useCss: ((css: string) => void) | undefined, onSuccess?: () => void) {
|
||||
return useMutation({ mutationFn: async (css: string) => useCss?.(css), onSuccess });
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
autoCancellation: vi.fn(),
|
||||
authWithOAuth2: vi.fn(),
|
||||
create: vi.fn(),
|
||||
getFullList: vi.fn(),
|
||||
getList: vi.fn(),
|
||||
send: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("pocketbase", () => {
|
||||
class MockPocketBase {
|
||||
public readonly authStore = {
|
||||
token: "",
|
||||
record: null as Record<string, unknown> | null,
|
||||
get isValid() {
|
||||
try {
|
||||
const payload = JSON.parse(atob(this.token.split(".")[1] ?? "")) as { exp?: unknown };
|
||||
return typeof payload.exp === "number" && payload.exp > Date.now() / 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
save: (token: string, record: Record<string, unknown>) => {
|
||||
this.authStore.token = token;
|
||||
this.authStore.record = record;
|
||||
},
|
||||
clear: () => {
|
||||
this.authStore.token = "";
|
||||
this.authStore.record = null;
|
||||
},
|
||||
};
|
||||
|
||||
public collection() {
|
||||
return mocks;
|
||||
}
|
||||
|
||||
public autoCancellation(enabled: boolean) {
|
||||
mocks.autoCancellation(enabled);
|
||||
return this;
|
||||
}
|
||||
|
||||
public send(path: string, options: unknown) {
|
||||
return mocks.send(path, options);
|
||||
}
|
||||
|
||||
public filter(template: string, values: Record<string, string>) {
|
||||
return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: MockPocketBase,
|
||||
ClientResponseError: class ClientResponseError extends Error {
|
||||
public constructor(public readonly status = 0) {
|
||||
super("PocketBase request failed");
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
import type { RecordService } from "pocketbase";
|
||||
|
||||
import { WorkshopBackend } from "./backend";
|
||||
import type { WorkshopSubmissionRecord } from "./backend";
|
||||
import * as workshopClient from "./client";
|
||||
|
||||
const validWorkshopToken = "header.eyJleHAiOjQxMDI0NDQ4MDB9.signature";
|
||||
|
||||
describe("WorkshopBackend", () => {
|
||||
test("resolves the advertised client module", () => {
|
||||
expect(workshopClient.WorkshopBackend).toBe(WorkshopBackend);
|
||||
expect(workshopClient.validateWorkshopContent).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.autoCancellation.mockReset();
|
||||
mocks.authWithOAuth2.mockReset();
|
||||
mocks.create.mockReset();
|
||||
mocks.getFullList.mockReset();
|
||||
mocks.getList.mockReset();
|
||||
mocks.send.mockReset();
|
||||
mocks.update.mockReset();
|
||||
});
|
||||
|
||||
test("lets PocketBase own the complete GitHub popup flow", async () => {
|
||||
mocks.authWithOAuth2.mockResolvedValue({
|
||||
token: validWorkshopToken,
|
||||
record: {
|
||||
id: "user-id",
|
||||
name: "octocat",
|
||||
},
|
||||
meta: { username: "octocat" },
|
||||
});
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const user = await client.signInWithGitHub();
|
||||
|
||||
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({ provider: "github" });
|
||||
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
|
||||
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
expect(user).toMatchObject({ name: "octocat" });
|
||||
});
|
||||
|
||||
test("exposes the typed submission collection service", () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
expectTypeOf(client.pocketBase.collection("submissions")).toEqualTypeOf<RecordService<WorkshopSubmissionRecord>>();
|
||||
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("reuses a valid Workshop session for the Homarr provider allowance", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
expect(client.authToken).toBeNull();
|
||||
client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id", name: "octocat" } as never);
|
||||
mocks.send.mockResolvedValue({
|
||||
limit: 50,
|
||||
used: 7,
|
||||
remaining: 43,
|
||||
resetsAt: "2026-08-12T00:00:00Z",
|
||||
});
|
||||
|
||||
await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 });
|
||||
expect(client.authToken).toBe(validWorkshopToken);
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
"/api/ai/usage",
|
||||
expect.objectContaining({ method: "GET", requestKey: null, signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed for expired tokens and malformed Workshop user records", () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
client.pocketBase.authStore.save("header.eyJleHAiOjF9.signature", {
|
||||
id: "author-id",
|
||||
name: "octocat",
|
||||
} as never);
|
||||
expect(client.currentUser).toBeNull();
|
||||
expect(client.authToken).toBeNull();
|
||||
|
||||
client.pocketBase.authStore.save(validWorkshopToken, { id: 42 } as never);
|
||||
expect(client.currentUser).toBeNull();
|
||||
expect(client.authToken).toBeNull();
|
||||
});
|
||||
|
||||
test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id" } as never);
|
||||
mocks.create.mockResolvedValue({ id: "submission-id" });
|
||||
vi.spyOn(client, "get").mockResolvedValue(listingRecord() as never);
|
||||
|
||||
await client.create({
|
||||
type: "customCss",
|
||||
title: "Dashboard theme",
|
||||
description: "A theme",
|
||||
content: ".dashboard { color: red; }",
|
||||
changelog: "",
|
||||
outdated: false,
|
||||
});
|
||||
|
||||
const payload = mocks.create.mock.calls[0]?.[0] as FormData;
|
||||
expect(payload.get("author")).toBe("author-id");
|
||||
expect(payload.get("widgetSchema")).toBe("homarr-custom-css-v1");
|
||||
expect(payload.get("revision")).toBeNull();
|
||||
});
|
||||
|
||||
test("appends and removes screenshots without replacing retained files", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const current = {
|
||||
...listingRecord(),
|
||||
type: "customCss" as const,
|
||||
content: ".dashboard { color: red; }",
|
||||
screenshots: ["keep.png", "remove.png"],
|
||||
revision: 2,
|
||||
};
|
||||
vi.spyOn(client, "get").mockResolvedValue(current as never);
|
||||
mocks.update.mockResolvedValue(current);
|
||||
const addition = new File(["image"], "new.png", { type: "image/png" });
|
||||
|
||||
await client.update(
|
||||
current.id,
|
||||
{
|
||||
type: "customCss",
|
||||
title: current.title,
|
||||
description: current.description,
|
||||
content: current.content,
|
||||
changelog: "Updated screenshots",
|
||||
outdated: false,
|
||||
},
|
||||
{ additions: [addition], removals: ["remove.png"] },
|
||||
);
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledWith(
|
||||
current.id,
|
||||
expect.objectContaining({
|
||||
"screenshots+": [addition],
|
||||
"screenshots-": ["remove.png"],
|
||||
expectedRevision: 2,
|
||||
}),
|
||||
);
|
||||
const payload = mocks.update.mock.calls[0]?.[1];
|
||||
expect(payload).not.toHaveProperty("revision");
|
||||
expect(payload).not.toHaveProperty("widgetSchema");
|
||||
});
|
||||
|
||||
test("uses the current revision when toggling submission status", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const current = {
|
||||
...listingRecord(),
|
||||
type: "customCss" as const,
|
||||
content: ".dashboard { color: red; }",
|
||||
revision: 4,
|
||||
outdated: false,
|
||||
};
|
||||
const updated = { ...current, revision: 5, outdated: true };
|
||||
vi.spyOn(client, "get")
|
||||
.mockResolvedValueOnce(current as never)
|
||||
.mockResolvedValueOnce(updated as never);
|
||||
mocks.update.mockResolvedValue(updated);
|
||||
|
||||
await expect(client.toggleOutdated(current.id, true)).resolves.toMatchObject({
|
||||
revision: 5,
|
||||
outdated: true,
|
||||
});
|
||||
expect(mocks.update).toHaveBeenCalledWith(current.id, {
|
||||
outdated: true,
|
||||
expectedRevision: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses PocketBase filtering and pagination when the listing view is current", async () => {
|
||||
mocks.getList.mockResolvedValue({
|
||||
page: 1,
|
||||
perPage: 12,
|
||||
totalPages: 1,
|
||||
totalItems: 1,
|
||||
items: [listingRecord()],
|
||||
});
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const result = await client.list({ type: "customWidget", sort: "top", page: 1, perPage: 12 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({ type: "customWidget", score: 0, revision: 1 });
|
||||
expect(mocks.getList).toHaveBeenCalledWith(
|
||||
1,
|
||||
12,
|
||||
expect.objectContaining({ filter: "type = 'customWidget'", sort: "-score,-created" }),
|
||||
);
|
||||
expect(mocks.getFullList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("searches listing titles, descriptions, and authors", async () => {
|
||||
mocks.getList.mockResolvedValue({
|
||||
page: 1,
|
||||
perPage: 12,
|
||||
totalPages: 1,
|
||||
totalItems: 1,
|
||||
items: [listingRecord()],
|
||||
});
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
await client.list({ search: "active streams", page: 1, perPage: 12 });
|
||||
|
||||
expect(mocks.getList).toHaveBeenCalledWith(
|
||||
1,
|
||||
12,
|
||||
expect.objectContaining({
|
||||
filter: "(title ~ 'active streams' || description ~ 'active streams' || authorName ~ 'active streams')",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("retries an incomplete listing view without missing-field filters", async () => {
|
||||
mocks.getList.mockRejectedValueOnce(new ClientResponseError(400));
|
||||
mocks.getFullList.mockResolvedValueOnce([listingRecord()]);
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const result = await client.list({ type: "customWidget" });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mocks.getFullList).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ filter: expect.anything(), sort: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to base submissions when an old listing view itself returns 400", async () => {
|
||||
mocks.getList.mockRejectedValueOnce(new ClientResponseError(400));
|
||||
mocks.getFullList.mockRejectedValueOnce(new ClientResponseError(400)).mockResolvedValueOnce([listingRecord()]);
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
const result = await client.list({ type: "customWidget" });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(mocks.getFullList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("replaces low-level network errors with an actionable Workshop outage message", async () => {
|
||||
mocks.getList.mockRejectedValueOnce(new Error("NetworkError when attempting to fetch resource."));
|
||||
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
|
||||
await expect(client.list({ type: "customWidget" })).rejects.toThrow(
|
||||
"Homarr Workshop seems to be unavailable right now. Try again in a moment.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function listingRecord() {
|
||||
return {
|
||||
id: "submission-id",
|
||||
title: "Tautulli Activity",
|
||||
description: "Shows active streams",
|
||||
widgetSchema: "homarr-custom-widget-v2",
|
||||
screenshots: [],
|
||||
author: "author-id",
|
||||
created: "2026-07-21 14:19:25.790Z",
|
||||
updated: "2026-07-21 14:19:25.790Z",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./backend";
|
||||
export * from "./schema";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./backend";
|
||||
export * from "./schema";
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
githubAvatarUrl,
|
||||
githubProfileUrl,
|
||||
normalizeHttpUrl,
|
||||
resolveHomarrUrlConfig,
|
||||
validateWorkshopWidget,
|
||||
workshopReportCategorySchema,
|
||||
workshopSubmissionInputSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
} from "./schema";
|
||||
|
||||
describe("Workshop URL configuration", () => {
|
||||
test("derives GitHub identity URLs from the stored username", () => {
|
||||
expect(githubProfileUrl("octocat")).toBe("https://github.com/octocat");
|
||||
expect(githubAvatarUrl("octocat")).toBe("https://github.com/octocat.png");
|
||||
});
|
||||
|
||||
test("normalizes the URL contract and derives Workshop defaults", () => {
|
||||
expect(
|
||||
resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: "https://docs.example.com/",
|
||||
workshopApiUrl: "https://api.example.com///",
|
||||
}),
|
||||
).toEqual({
|
||||
homarrWebsiteUrl: "https://docs.example.com",
|
||||
workshopApiUrl: "https://api.example.com",
|
||||
workshopWebUrl: "https://docs.example.com/workshop",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses an explicit Workshop web URL for public Workshop links", () => {
|
||||
expect(
|
||||
resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: "https://homarr.example.com",
|
||||
workshopApiUrl: "https://api.example.com",
|
||||
workshopWebUrl: "https://community.example.com/workshop",
|
||||
}),
|
||||
).toEqual({
|
||||
homarrWebsiteUrl: "https://homarr.example.com",
|
||||
workshopApiUrl: "https://api.example.com",
|
||||
workshopWebUrl: "https://community.example.com/workshop",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unsafe or ambiguous public URLs", () => {
|
||||
expect(() => normalizeHttpUrl("file:///tmp/workshop", "WORKSHOP_API_URL")).toThrow("HTTP or HTTPS");
|
||||
expect(() => normalizeHttpUrl("https://user:secret@example.com", "WORKSHOP_API_URL")).toThrow("credentials");
|
||||
expect(() => normalizeHttpUrl("https://example.com/?token=secret", "WORKSHOP_API_URL")).toThrow("query string");
|
||||
expect(() => normalizeHttpUrl("\nhttps://example.com", "WORKSHOP_API_URL")).toThrow("control characters");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workshop widget validation", () => {
|
||||
test("accepts outdated as a report category", () => {
|
||||
expect(workshopReportCategorySchema.parse("outdated")).toBe("outdated");
|
||||
});
|
||||
|
||||
test("accepts a canonical credential-free widget", () => {
|
||||
expect(validateWorkshopWidget(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(true);
|
||||
expect(
|
||||
workshopSubmissionInputSchema.safeParse({
|
||||
type: "customWidget",
|
||||
title: "Starter widget",
|
||||
description: "",
|
||||
content: JSON.stringify(CUSTOM_WIDGET_STARTER),
|
||||
}).success,
|
||||
).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)).toMatchObject(CUSTOM_WIDGET_STARTER);
|
||||
});
|
||||
|
||||
test("accepts Custom CSS without treating it as widget JSON", () => {
|
||||
expect(
|
||||
workshopSubmissionInputSchema.safeParse({
|
||||
type: "customCss",
|
||||
title: "Quiet dashboard",
|
||||
description: "",
|
||||
content: ".mantine-Card-root { box-shadow: none; }",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("validates direct bindings in the official client before publication", () => {
|
||||
const widget = {
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: {
|
||||
data: {
|
||||
path: "/data/{option:endpointId}",
|
||||
query: { limit: 5 },
|
||||
},
|
||||
},
|
||||
options: { endpointId: { label: "Endpoint", control: "text", default: "local" } },
|
||||
};
|
||||
|
||||
expect(validateWorkshopWidget(JSON.stringify(widget)).success).toBe(true);
|
||||
expect(
|
||||
validateWorkshopWidget(JSON.stringify({ ...widget, requests: { data: { path: "/data/{option:missing}" } } }))
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateWorkshopWidget(
|
||||
JSON.stringify({
|
||||
...widget,
|
||||
requests: { data: { path: "/data/{param:endpointId}" } },
|
||||
}),
|
||||
).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes PocketBase records before rendering", () => {
|
||||
const result = workshopSubmissionSummarySchema.parse({
|
||||
id: "jzyxskmimxn42io",
|
||||
title: "Tautulli Activity",
|
||||
description: "Shows active Plex streams from Tautulli",
|
||||
widgetSchema: "homarr-custom-widget-v2",
|
||||
screenshots: "[]",
|
||||
author: "wsqylmotymtqpox",
|
||||
revision: 1,
|
||||
created: "2026-07-21 14:19:25.790Z",
|
||||
updated: "2026-07-21 14:19:25.790Z",
|
||||
});
|
||||
|
||||
expect(result.type).toBe("customWidget");
|
||||
expect(result.screenshots).toEqual([]);
|
||||
expect(result.authorName).toBe("");
|
||||
expect(result.upvotes).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects literal credentials in templates and icon URLs", () => {
|
||||
expect(
|
||||
validateWorkshopWidget(
|
||||
JSON.stringify({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
template: "<Text>Authorization: Bearer literal-secret-value</Text>",
|
||||
}),
|
||||
).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateWorkshopWidget(
|
||||
JSON.stringify({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
iconUrl: "https://example.com/icon.png?clientSecret=literal-secret",
|
||||
}),
|
||||
).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateWorkshopWidget(
|
||||
JSON.stringify({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: { load: { path: "/load", headers: { "X-Api-Key": "literal-secret-value" } } },
|
||||
}),
|
||||
).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { exportCustomWidgetDefinition } from "@homarr/custom-widgets/core";
|
||||
import { CUSTOM_WIDGET_SCHEMA, customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const HOMARR_WEBSITE_URL = "https://homarr.dev";
|
||||
export const WORKSHOP_API_URL = HOMARR_WEBSITE_URL;
|
||||
export const WORKSHOP_WEB_URL = `${HOMARR_WEBSITE_URL}/workshop`;
|
||||
export const WORKSHOP_CSS_SCHEMA = "homarr-custom-css-v1";
|
||||
export const WORKSHOP_SCHEMA_BY_TYPE = {
|
||||
customWidget: CUSTOM_WIDGET_SCHEMA,
|
||||
customCss: WORKSHOP_CSS_SCHEMA,
|
||||
} as const;
|
||||
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;
|
||||
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
|
||||
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
export const githubProfileUrl = (username: string) =>
|
||||
username ? `https://github.com/${encodeURIComponent(username)}` : "";
|
||||
export const githubAvatarUrl = (username: string) =>
|
||||
username ? `https://github.com/${encodeURIComponent(username)}.png` : "";
|
||||
|
||||
export interface HomarrUrlConfig {
|
||||
homarrWebsiteUrl: string;
|
||||
workshopApiUrl: string;
|
||||
workshopWebUrl: string;
|
||||
}
|
||||
|
||||
export interface HomarrUrlConfigInput {
|
||||
homarrWebsiteUrl?: string;
|
||||
workshopApiUrl?: string;
|
||||
workshopWebUrl?: string;
|
||||
}
|
||||
|
||||
export function normalizeHttpUrl(value: string, variableName: string): string {
|
||||
if (value !== value.trim() || hasAsciiControl(value)) {
|
||||
throw new Error(`${variableName} must not include surrounding whitespace or control characters`);
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${variableName} must be a valid absolute HTTP(S) URL`);
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`${variableName} must use HTTP or HTTPS`);
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error(`${variableName} must not include credentials`);
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
throw new Error(`${variableName} must not include a query string or fragment`);
|
||||
}
|
||||
|
||||
return url.toString().replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function hasAsciiControl(value: string): boolean {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveHomarrUrlConfig(input: HomarrUrlConfigInput = {}): HomarrUrlConfig {
|
||||
const homarrWebsiteUrl = normalizeHttpUrl(input.homarrWebsiteUrl ?? HOMARR_WEBSITE_URL, "HOMARR_WEBSITE_URL");
|
||||
const workshopApiUrl = normalizeHttpUrl(input.workshopApiUrl ?? homarrWebsiteUrl, "WORKSHOP_API_URL");
|
||||
const workshopWebUrl = normalizeHttpUrl(input.workshopWebUrl ?? `${homarrWebsiteUrl}/workshop`, "WORKSHOP_WEB_URL");
|
||||
|
||||
return { homarrWebsiteUrl, workshopApiUrl, workshopWebUrl };
|
||||
}
|
||||
|
||||
export const workshopReportCategorySchema = z.enum([
|
||||
"outdated",
|
||||
"malicious",
|
||||
"spam",
|
||||
"copyright",
|
||||
"inappropriate",
|
||||
"other",
|
||||
]);
|
||||
export const workshopSubmissionTypeSchema = z.enum(["customWidget", "customCss"]);
|
||||
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;
|
||||
|
||||
const workshopFileListSchema = z
|
||||
.preprocess((value) => {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, z.array(z.string()))
|
||||
.default([]);
|
||||
|
||||
export const workshopUserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().default(""),
|
||||
isAdmin: z.boolean().default(false),
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
|
||||
export const workshopAssistantUsageSchema = z.object({
|
||||
limit: z.number().int().nonnegative(),
|
||||
used: z.number().int().nonnegative(),
|
||||
remaining: z.number().int().nonnegative(),
|
||||
resetsAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
export type WorkshopAssistantUsage = z.infer<typeof workshopAssistantUsageSchema>;
|
||||
|
||||
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "customWidget" || record.type === "customCss") return value;
|
||||
if (record.widgetSchema === WORKSHOP_CSS_SCHEMA) return { ...record, type: "customCss" };
|
||||
if (typeof record.widgetSchema === "string" && record.widgetSchema.startsWith("homarr-custom-widget-"))
|
||||
return { ...record, type: "customWidget" };
|
||||
return value;
|
||||
};
|
||||
|
||||
const workshopSubmissionBaseObjectSchema = 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().default(""),
|
||||
score: z.number().int().default(0),
|
||||
upvotes: z.number().int().default(0),
|
||||
downvotes: z.number().int().default(0),
|
||||
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(),
|
||||
});
|
||||
|
||||
export const workshopSubmissionSummarySchema = z.preprocess(
|
||||
normalizeWorkshopSubmissionRecord,
|
||||
workshopSubmissionBaseObjectSchema,
|
||||
);
|
||||
export type WorkshopSubmissionSummary = z.infer<typeof workshopSubmissionSummarySchema>;
|
||||
export const workshopSubmissionDetailSchema = z.preprocess(
|
||||
normalizeWorkshopSubmissionRecord,
|
||||
workshopSubmissionBaseObjectSchema.extend({ content: z.string() }),
|
||||
);
|
||||
export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSchema>;
|
||||
|
||||
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 = 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
|
||||
.array(
|
||||
z.object({
|
||||
size: z.number().max(MAX_WORKSHOP_SCREENSHOT_BYTES),
|
||||
type: z.enum(WORKSHOP_SCREENSHOT_MIME_TYPES),
|
||||
}),
|
||||
)
|
||||
.max(MAX_WORKSHOP_SCREENSHOTS);
|
||||
|
||||
export const workshopVoteSchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
user: z.string(),
|
||||
value: z.union([z.literal(1), z.literal(-1)]),
|
||||
});
|
||||
export type WorkshopVote = z.infer<typeof workshopVoteSchema>;
|
||||
|
||||
export const workshopReportSchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
reporter: z.string(),
|
||||
reporterName: z.string().default(""),
|
||||
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 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(),
|
||||
author: z.string(),
|
||||
content: z.string().min(1).max(2_000),
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
authorName: 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 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 formatWorkshopContent(type: WorkshopSubmissionType, content: string): string {
|
||||
if (type !== "customWidget") return content;
|
||||
const validation = validateWorkshopWidget(content);
|
||||
if (!validation.success) return content;
|
||||
return JSON.stringify(validation.data, null, 2);
|
||||
}
|
||||
|
||||
export function validateWorkshopWidget(content: string): WorkshopWidgetValidationResult {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const error = result.error.issues
|
||||
.slice(0, 5)
|
||||
.map((issue) => `${issue.path.join(".") || "widget"}: ${issue.message}`)
|
||||
.join("\n");
|
||||
return { success: false, error: error || "Invalid widget" };
|
||||
}
|
||||
return { success: true, data: exportCustomWidgetDefinition(result.data) };
|
||||
} catch {
|
||||
return { success: false, error: "Widget content is not valid JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
export function workshopExportFilename(title: string, type: WorkshopSubmissionType = "customWidget") {
|
||||
const safe =
|
||||
title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/^-|-$/g, "") || "homarr-widget";
|
||||
return `${safe}.${type === "customCss" ? "css" : "json"}`;
|
||||
}
|
||||
Reference in new issue
Block a user