import PocketBase, { ClientResponseError } from "pocketbase"; import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core"; import type { WorkshopComment, WorkshopReport, WorkshopSubmissionDetail, WorkshopSubmissionInput, WorkshopSubmissionSummary, WorkshopSubmissionType, WorkshopUser, WorkshopVote, } from "./schema"; import { WORKSHOP_API_URL, 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 { page: number; perPage: number; totalPages: number; totalItems: number; items: T[]; } 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 | undefined, record: Record) { const rawUser = meta?.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record) : 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 listingSort = (sort: WorkshopListOptions["sort"]) => { if (sort === "newest") return "-created"; if (sort === "recent") return "-updated"; if (sort === "discussed") return "-commentCount,-created"; return "-score,-created"; }; 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 new Error(message, { cause: error }); } 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 WorkshopClient { public readonly pocketBase: PocketBase; public constructor(public readonly baseUrl = WORKSHOP_API_URL) { this.pocketBase = new PocketBase(baseUrl.replace(/\/$/u, "")); } 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: record.avatarUrl || (record.avatar ? this.pocketBase.files.getURL(record, record.avatar) : ""), githubUsername: record.githubUsername || record.username || "", githubProfileUrl: record.githubProfileUrl || "", 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> { const filters: string[] = []; if (options.type && options.type !== "all") filters.push(this.pocketBase.filter("type = {:type}", { type: options.type })); if (options.search?.trim()) filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() })); if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author })); if (options.includeOutdated === false) filters.push("outdated = false"); try { const result = await this.pocketBase .collection("workshop_listings") .getList(options.page ?? 1, options.perPage ?? 24, { filter: filters.join(" && "), sort: listingSort(options.sort), signal: requestSignal(options.signal), }); return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) }; } catch (error) { throw workshopError(error, "Failed to load Workshop submissions"); } } public async listAll(options: Omit = {}) { const filters: string[] = []; if (options.type && options.type !== "all") filters.push(this.pocketBase.filter("type = {:type}", { type: options.type })); if (options.search?.trim()) filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() })); if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author })); if (options.includeOutdated === false) filters.push("outdated = false"); try { const items = await this.pocketBase.collection("workshop_listings").getFullList({ batch: 200, filter: filters.join(" && "), sort: listingSort(options.sort), signal: requestSignal(options.signal), }); return items.map((item) => workshopSubmissionSummarySchema.parse(item)); } catch (error) { throw workshopError(error, "Failed to load Workshop submissions"); } } public async get(id: string, signal?: AbortSignal): Promise { try { const [submission, listing] = await Promise.all([ this.pocketBase.collection("submissions").getOne(id, { signal: requestSignal(signal) }), this.pocketBase.collection("workshop_listings").getOne(id, { signal: requestSignal(signal) }), ]); return workshopSubmissionDetailSchema.parse({ ...submission, ...listing, 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("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, screenshots: File[] = []) { const parsed = workshopSubmissionInputSchema.parse(input); workshopScreenshotsSchema.parse(screenshots); const current = await this.get(id); 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("revision", String(current.revision + 1)); screenshots.forEach((file) => data.append("screenshots", file)); 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 { 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 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 { 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 { 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?.avatarUrl, authorGithubProfileUrl: row.expand?.author?.githubProfileUrl, }), ); } catch (error) { throw workshopError(error, "Failed to load comments"); } } public async createComment(submission: string, content: string): Promise { 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?.avatarUrl, authorGithubProfileUrl: row.expand?.author?.githubProfileUrl, }); } catch (error) { throw workshopError(error, "Failed to post comment"); } } public async updateComment(id: string, content: string): Promise { 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?.avatarUrl, authorGithubProfileUrl: row.expand?.author?.githubProfileUrl, }); } 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"); } } }