diff --git a/web/src/app/actions/github.ts b/web/src/app/actions/github.ts index 4cd8ebb8..c09244d6 100644 --- a/web/src/app/actions/github.ts +++ b/web/src/app/actions/github.ts @@ -1,52 +1,48 @@ -"use server"; +"use server" -import PocketBase from "pocketbase"; +import PocketBase from "pocketbase" -const GITHUB_OWNER = "homarr-labs"; -const GITHUB_REPO = "dashboard-icons"; -const WORKFLOW_FILE = "add-icon.yml"; +const GITHUB_OWNER = "homarr-labs" +const GITHUB_REPO = "dashboard-icons" +const WORKFLOW_FILE = "add-icon.yml" interface TriggerWorkflowResult { - success: boolean; - error?: string; - workflowUrl?: string; + success: boolean + error?: string + workflowUrl?: string } /** * Verify the provided auth token belongs to an admin user * The token is passed from the client since auth is stored in localStorage */ -async function verifyAdmin( - authToken: string, -): Promise<{ isAdmin: boolean; error?: string }> { +async function verifyAdmin(authToken: string): Promise<{ isAdmin: boolean; error?: string }> { if (!authToken) { - return { isAdmin: false, error: "Not authenticated" }; + return { isAdmin: false, error: "Not authenticated" } } try { - const pb = new PocketBase( - process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090", - ); + const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090") // Validate the token by refreshing auth // This will fail if the token is invalid/expired - pb.authStore.save(authToken, null); + pb.authStore.save(authToken, null) - const authData = await pb.collection("users").authRefresh(); + const authData = await pb.collection("users").authRefresh() if (!authData?.record) { - return { isAdmin: false, error: "Invalid authentication" }; + return { isAdmin: false, error: "Invalid authentication" } } // Check if user is admin if (!authData.record.admin) { - return { isAdmin: false, error: "User is not an admin" }; + return { isAdmin: false, error: "User is not an admin" } } - return { isAdmin: true }; + return { isAdmin: true } } catch (error) { - console.error("Error verifying admin:", error); - return { isAdmin: false, error: "Failed to verify admin status" }; + console.error("Error verifying admin:", error) + return { isAdmin: false, error: "Failed to verify admin status" } } } @@ -57,21 +53,17 @@ async function verifyAdmin( * @param submissionId - The ID of the submission to add * @param dryRun - If true, skip actual writes (for testing) */ -export async function triggerAddIconWorkflow( - authToken: string, - submissionId: string, - dryRun = false, -): Promise { +export async function triggerAddIconWorkflow(authToken: string, submissionId: string, dryRun = false): Promise { // Verify admin status using the provided token - const { isAdmin, error: authError } = await verifyAdmin(authToken); + const { isAdmin, error: authError } = await verifyAdmin(authToken) if (!isAdmin) { - return { success: false, error: authError || "Unauthorized" }; + return { success: false, error: authError || "Unauthorized" } } // Check for GitHub token - const githubToken = process.env.GITHUB_TOKEN; + const githubToken = process.env.GITHUB_TOKEN if (!githubToken) { - return { success: false, error: "GitHub token not configured" }; + return { success: false, error: "GitHub token not configured" } } try { @@ -93,31 +85,30 @@ export async function triggerAddIconWorkflow( }, }), }, - ); + ) if (!response.ok) { - const errorText = await response.text(); - console.error("GitHub API error:", response.status, errorText); + const errorText = await response.text() + console.error("GitHub API error:", response.status, errorText) return { success: false, error: `GitHub API error: ${response.status} - ${errorText}`, - }; + } } // The dispatch endpoint returns 204 No Content on success // Construct a URL to the workflow runs page - const workflowUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${WORKFLOW_FILE}`; + const workflowUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/actions/workflows/${WORKFLOW_FILE}` return { success: true, workflowUrl, - }; + } } catch (error) { - console.error("Error triggering workflow:", error); + console.error("Error triggering workflow:", error) return { success: false, - error: - error instanceof Error ? error.message : "Failed to trigger workflow", - }; + error: error instanceof Error ? error.message : "Failed to trigger workflow", + } } } diff --git a/web/src/hooks/use-submissions.ts b/web/src/hooks/use-submissions.ts index 76ae7ebb..20bb6c65 100644 --- a/web/src/hooks/use-submissions.ts +++ b/web/src/hooks/use-submissions.ts @@ -1,42 +1,39 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { triggerAddIconWorkflow } from "@/app/actions/github"; -import { revalidateAllSubmissions } from "@/app/actions/submissions"; -import { getAllIcons } from "@/lib/api"; -import { pb, type Submission } from "@/lib/pb"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { triggerAddIconWorkflow } from "@/app/actions/github" +import { revalidateAllSubmissions } from "@/app/actions/submissions" +import { getAllIcons } from "@/lib/api" +import { pb, type Submission } from "@/lib/pb" // Query key factory export const submissionKeys = { all: ["submissions"] as const, lists: () => [...submissionKeys.all, "list"] as const, - list: (filters?: Record) => - [...submissionKeys.lists(), filters] as const, -}; + list: (filters?: Record) => [...submissionKeys.lists(), filters] as const, +} // Fetch all submissions export function useSubmissions() { return useQuery({ queryKey: submissionKeys.lists(), queryFn: async () => { - const records = await pb - .collection("submissions") - .getFullList({ - sort: "-updated", - expand: "created_by,approved_by", - requestKey: null, - }); + const records = await pb.collection("submissions").getFullList({ + sort: "-updated", + expand: "created_by,approved_by", + requestKey: null, + }) if (records.length > 0) { } - return records; + return records }, - }); + }) } // Approve submission mutation export function useApproveSubmission() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: async (submissionId: string) => { @@ -49,45 +46,36 @@ export function useApproveSubmission() { { requestKey: null, }, - ); + ) }, onSuccess: async (_data) => { // Invalidate and refetch submissions - queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }); + queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }) // Revalidate Next.js cache for community pages - await revalidateAllSubmissions(); + await revalidateAllSubmissions() toast.success("Submission approved", { description: "The submission has been approved successfully", - }); + }) }, onError: (error: any) => { - console.error("Error approving submission:", error); - if ( - !error.message?.includes("autocancelled") && - !error.name?.includes("AbortError") - ) { + console.error("Error approving submission:", error) + if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) { toast.error("Failed to approve submission", { description: error.message || "An error occurred", - }); + }) } }, - }); + }) } // Reject submission mutation export function useRejectSubmission() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ - submissionId, - adminComment, - }: { - submissionId: string; - adminComment?: string; - }) => { + mutationFn: async ({ submissionId, adminComment }: { submissionId: string; adminComment?: string }) => { return await pb.collection("submissions").update( submissionId, { @@ -98,31 +86,28 @@ export function useRejectSubmission() { { requestKey: null, }, - ); + ) }, onSuccess: async () => { // Invalidate and refetch submissions - queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }); + queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }) // Revalidate Next.js cache for community pages - await revalidateAllSubmissions(); + await revalidateAllSubmissions() toast.success("Submission rejected", { description: "The submission has been rejected", - }); + }) }, onError: (error: any) => { - console.error("Error rejecting submission:", error); - if ( - !error.message?.includes("autocancelled") && - !error.name?.includes("AbortError") - ) { + console.error("Error rejecting submission:", error) + if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) { toast.error("Failed to reject submission", { description: error.message || "An error occurred", - }); + }) } }, - }); + }) } // Fetch existing icon names for the combobox + the metadata.json file @@ -134,28 +119,22 @@ export function useExistingIconNames() { fields: "name", sort: "name", requestKey: null, - }); + }) - const metadata = await getAllIcons(); - const metadataNames = Object.keys(metadata); + const metadata = await getAllIcons() + const metadataNames = Object.keys(metadata) - const uniqueRecordsNames = Array.from( - new Set(records.map((r) => r.name)), - ); - const uniqueMetadataNames = Array.from( - new Set(metadataNames.map((n) => n)), - ); - const uniqueNames = Array.from( - new Set(uniqueRecordsNames.concat(uniqueMetadataNames)), - ); + const uniqueRecordsNames = Array.from(new Set(records.map((r) => r.name))) + const uniqueMetadataNames = Array.from(new Set(metadataNames.map((n) => n))) + const uniqueNames = Array.from(new Set(uniqueRecordsNames.concat(uniqueMetadataNames))) return uniqueNames.map((name) => ({ label: name, value: name, - })); + })) }, staleTime: 5 * 60 * 1000, // 5 minutes retry: false, - }); + }) } // Check authentication status @@ -163,67 +142,57 @@ export function useAuth() { return useQuery({ queryKey: ["auth"], queryFn: async () => { - const isValid = pb.authStore.isValid; - const userId = pb.authStore.record?.id; + const isValid = pb.authStore.isValid + const userId = pb.authStore.record?.id if (!isValid || !userId) { return { isAuthenticated: false, isAdmin: false, userId: "", - }; + } } try { // Fetch the full user record to get the admin status const user = await pb.collection("users").getOne(userId, { requestKey: null, - }); + }) return { isAuthenticated: true, isAdmin: user?.admin === true, userId: userId, - }; + } } catch (error) { - console.error("Error fetching user:", error); + console.error("Error fetching user:", error) return { isAuthenticated: isValid, isAdmin: false, userId: userId || "", - }; + } } }, staleTime: 5 * 60 * 1000, // 5 minutes retry: false, - }); + }) } // Trigger GitHub workflow to add icon to collection export function useTriggerWorkflow() { return useMutation({ - mutationFn: async ({ - submissionId, - dryRun = false, - }: { - submissionId: string; - dryRun?: boolean; - }) => { + mutationFn: async ({ submissionId, dryRun = false }: { submissionId: string; dryRun?: boolean }) => { // Get the auth token from the client-side PocketBase instance - const authToken = pb.authStore.token; + const authToken = pb.authStore.token if (!authToken) { - throw new Error("Not authenticated"); + throw new Error("Not authenticated") } - const result = await triggerAddIconWorkflow( - authToken, - submissionId, - dryRun, - ); + const result = await triggerAddIconWorkflow(authToken, submissionId, dryRun) if (!result.success) { - throw new Error(result.error || "Failed to trigger workflow"); + throw new Error(result.error || "Failed to trigger workflow") } - return result; + return result }, onSuccess: (data) => { toast.success("GitHub workflow triggered", { @@ -234,13 +203,13 @@ export function useTriggerWorkflow() { onClick: () => window.open(data.workflowUrl, "_blank"), } : undefined, - }); + }) }, onError: (error: Error) => { - console.error("Error triggering workflow:", error); + console.error("Error triggering workflow:", error) toast.error("Failed to trigger workflow", { description: error.message || "An error occurred", - }); + }) }, - }); + }) } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5c33a07c..afd8c58a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -149,10 +149,7 @@ function buildInternalAuthorData(author: { id: string | number; name?: string; l * This prevents hitting GitHub API rate limits by caching author data * across multiple page builds and requests. */ -export async function getAuthorData( - authorId: number | string, - authorMeta?: { name?: string; login?: string }, -): Promise { +export async function getAuthorData(authorId: number | string, authorMeta?: { name?: string; login?: string }): Promise { const cacheKey = String(authorId) if (authorDataCache[cacheKey]) {