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, 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, 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 { 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 { 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; collection(idOrName: "submissions"): RecordService; collection(idOrName: "workshop_listings"): RecordService; collection(idOrName: "votes"): RecordService; collection(idOrName: "reports"): RecordService; collection(idOrName: "workshop_report_summaries"): RecordService; collection(idOrName: "comments"): RecordService; 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; const expand = record.expand; const author = typeof expand === "object" && expand !== null && !Array.isArray(expand) ? (expand as Record).author : undefined; const user = typeof author === "object" && author !== null && !Array.isArray(author) ? (author as Record) : {}; 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 { const record = this.pocketBase.authStore.record; if (!record) return null; return workshopUserSchema.parse({ id: record.id, name: record.name || "", 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", }); this.pocketBase.authStore.save(auth.token, auth.record); return this.currentUser; } catch (error) { throw workshopError(error, "GitHub sign-in failed"); } } public signOut() { this.pocketBase.authStore.clear(); } public async list(options: WorkshopListOptions = {}): Promise> { 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 = {}) { 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 { 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 = { ...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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, }; 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, }); 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) => 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)); } 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) }), 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()]), }); } 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) | 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 }); }