feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

+893
View File
@@ -0,0 +1,893 @@
import PocketBase, { ClientResponseError } from "pocketbase";
import type { RecordService } from "pocketbase";
import {
keepPreviousData,
mutationOptions,
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import type {
WorkshopComment,
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionInput,
WorkshopSubmissionSummary,
WorkshopSubmissionType,
WorkshopUser,
WorkshopVote,
} from "./schema";
import {
WORKSHOP_API_URL,
MAX_WORKSHOP_SCREENSHOTS,
WORKSHOP_REQUEST_TIMEOUT_MS,
workshopCommentSchema,
workshopReportSchema,
workshopSubmissionDetailSchema,
workshopSubmissionSummarySchema,
workshopSubmissionInputSchema,
workshopScreenshotsSchema,
workshopUserSchema,
workshopVoteSchema,
WORKSHOP_CSS_SCHEMA,
} 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;
username: string;
displayName: string;
avatarUrl: string;
avatar: string;
githubUsername: string;
githubProfileUrl: 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;
authorAvatar: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: 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 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: "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)],
);
function oauthText(...values: unknown[]) {
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
}
function githubProfile(meta: Record<string, unknown> | undefined, record: { id: string }) {
const rawUser =
meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : undefined;
const displayName = oauthText(meta?.name, meta?.username, rawUser?.name, rawUser?.login);
const avatarUrl = oauthText(meta?.avatarUrl, meta?.avatarURL, rawUser?.avatar_url);
const githubUsername = oauthText(meta?.username, rawUser?.login);
return {
displayName: (displayName ?? `GitHub user ${String(record.id ?? "").slice(0, 8)}`).slice(0, 100),
avatarUrl: avatarUrl?.startsWith("https://") ? avatarUrl : undefined,
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : undefined,
};
}
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,
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) => {
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>) : {};
const githubUsername = record.authorGithubUsername || user.githubUsername || user.username;
return {
...record,
authorName: record.authorName || user.displayName || user.username,
authorAvatarUrl: resolveAvatarUrl?.(record, user) || record.authorAvatarUrl || user.avatarUrl,
authorGithubUsername: githubUsername,
authorGithubProfileUrl:
record.authorGithubProfileUrl ||
user.githubProfileUrl ||
(typeof githubUsername === "string" && githubUsername
? `https://github.com/${encodeURIComponent(githubUsername)}`
: undefined),
};
};
const parseListings = (
rows: unknown[],
resolveAvatarUrl?: (record: Record<string, unknown>, user: Record<string, unknown>) => string,
) =>
rows.flatMap((row) => {
const result = workshopSubmissionSummarySchema.safeParse(enrichAuthor(row, resolveAvatarUrl));
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) {
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);
}
private userAvatarUrl(record: Record<string, unknown>) {
const avatar = typeof record.avatar === "string" ? record.avatar : "";
if (avatar) return this.pocketBase.files.getURL(record, avatar);
return typeof record.avatarUrl === "string" ? record.avatarUrl : "";
}
private listingAuthorAvatarUrl(record: Record<string, unknown>, user: Record<string, unknown>) {
const expandedAvatarUrl = this.userAvatarUrl(user);
if (expandedAvatarUrl) return expandedAvatarUrl;
const author = typeof record.author === "string" ? record.author : "";
const avatar = typeof record.authorAvatar === "string" ? record.authorAvatar : "";
if (!author || !avatar) return "";
return `${this.baseUrl}/api/files/users/${encodeURIComponent(author)}/${encodeURIComponent(avatar)}`;
}
public get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: this.userAvatarUrl(record),
githubUsername: record.githubUsername || "",
githubProfileUrl:
record.githubProfileUrl ||
(record.githubUsername ? `https://github.com/${encodeURIComponent(record.githubUsername)}` : ""),
isAdmin: record.isAdmin === true,
});
}
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
}
public async refreshAuth() {
if (!this.pocketBase.authStore.isValid) 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",
createData: { displayName: "GitHub user" },
});
const profile = githubProfile(auth.meta, auth.record);
const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
this.pocketBase.authStore.save(auth.token, updated);
return this.currentUser;
} catch (error) {
throw workshopError(error, "GitHub sign-in failed");
}
}
public signOut() {
this.pocketBase.authStore.clear();
}
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, (record, user) => this.listingAuthorAvatarUrl(record, user)),
};
} 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, (record, user) => this.listingAuthorAvatarUrl(record, user));
} 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, (record, user) => this.listingAuthorAvatarUrl(record, user));
}
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 },
(record, user) => this.listingAuthorAvatarUrl(record, user),
),
);
} 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("widgetSchema", parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA);
data.set("author", this.currentUser?.id ?? "");
data.set("revision", "1");
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,
widgetSchema: parsed.type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA,
revision: current.revision + 1,
};
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?.displayName,
submissionTitle: item.expand?.submission?.title,
}),
);
} catch (error) {
throw workshopError(error, "Failed to load reports");
}
}
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 {
await this.pocketBase.collection("submissions").update(id, { outdated });
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?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
}),
);
} 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?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
});
} 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?.displayName,
authorAvatarUrl: row.expand?.author ? this.userAvatarUrl(row.expand.author) : undefined,
authorGithubProfileUrl:
row.expand?.author?.githubProfileUrl ||
(row.expand?.author?.githubUsername
? `https://github.com/${encodeURIComponent(row.expand.author.githubUsername)}`
: undefined),
});
} 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,
comments: (backend: WorkshopBackend, submission: string) =>
[...workshopQueryKeys.all, "comments", backend.baseUrl, submission] 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 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 useWorkshopCommentsQuery(backend: WorkshopBackend, submission: string) {
return useQuery(workshopCommentsQueryOptions(backend, submission));
}
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) }),
removeDetail: (id: string) => queryClient.removeQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
reports: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reports(backend) }),
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()]),
});
}
export function useWorkshopReportMutation(backend: WorkshopBackend) {
const invalidation = useWorkshopInvalidation(backend);
return useMutation({
...workshopReportMutationOptions(backend),
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.reports()]),
});
}
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 });
}
+200
View File
@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
autoCancellation: vi.fn(),
authWithOAuth2: vi.fn(),
getFullList: vi.fn(),
getList: vi.fn(),
update: vi.fn(),
}));
vi.mock("pocketbase", () => {
class MockPocketBase {
public readonly authStore = {
record: null as Record<string, unknown> | null,
isValid: false,
save: (_token: string, record: Record<string, unknown>) => {
this.authStore.record = record;
},
};
public collection() {
return mocks;
}
public autoCancellation(enabled: boolean) {
mocks.autoCancellation(enabled);
return this;
}
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";
describe("WorkshopBackend", () => {
beforeEach(() => {
mocks.autoCancellation.mockReset();
mocks.authWithOAuth2.mockReset();
mocks.getFullList.mockReset();
mocks.getList.mockReset();
mocks.update.mockReset();
});
test("lets PocketBase own the complete GitHub popup flow", async () => {
mocks.authWithOAuth2.mockResolvedValue({
token: "token",
record: { id: "user-id" },
meta: { username: "octocat" },
});
mocks.update.mockResolvedValue({ id: "user-id", displayName: "octocat" });
const client = new WorkshopBackend("https://workshop.example.com");
await client.signInWithGitHub();
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
provider: "github",
createData: { displayName: "GitHub user" },
});
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
});
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("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"],
revision: 3,
}),
);
});
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);
});
});
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",
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./backend";
export * from "./schema";
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { validateWorkshopWidget, workshopSubmissionInputSchema, workshopSubmissionSummarySchema } from "./schema";
describe("Workshop widget validation", () => {
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("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("uses canonical direct-binding validation 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("Community member");
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);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
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;
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) => {
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(),
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 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("Community member"),
authorAvatarUrl: z.string().default(""),
authorGithubUsername: z.string().default(""),
authorGithubProfileUrl: 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 });
});
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("Community member"),
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 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 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);
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: 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"}`;
}