From d3760a2ed720806a09ef580fcbbd1cbf97752fc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:44:30 +0000 Subject: [PATCH 1/4] Initial plan From 2d521a814b0e4942fb73ebee6e69055721bfa2da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:50:09 +0000 Subject: [PATCH 2/4] Enable GitHub OAuth login in login modal Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- web/src/app/actions/github.ts | 87 +++++++++---------- web/src/app/dashboard/page.tsx | 13 ++- ...advanced-icon-submission-form-tanstack.tsx | 1 - web/src/components/icon-customizer-inline.tsx | 50 +++-------- web/src/components/icon-details.tsx | 43 ++++----- web/src/components/icon-submission-form.tsx | 3 +- web/src/components/login-modal.tsx | 54 ++++++++++-- web/src/components/submissions-data-table.tsx | 9 +- web/src/lib/svg-color-utils.ts | 39 ++++----- 9 files changed, 151 insertions(+), 148 deletions(-) diff --git a/web/src/app/actions/github.ts b/web/src/app/actions/github.ts index 986a3120..f0ba4da2 100644 --- a/web/src/app/actions/github.ts +++ b/web/src/app/actions/github.ts @@ -1,52 +1,48 @@ "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 submissionIds - Single ID or comma-separated IDs of submissions to add * @param dryRun - If true, skip actual writes (for testing) */ -export async function triggerAddIconWorkflow( - authToken: string, - submissionIds: string, - dryRun = false, -): Promise { +export async function triggerAddIconWorkflow(authToken: string, submissionIds: 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,40 +85,39 @@ 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", + } } } interface BulkTriggerWorkflowResult { - success: boolean; - error?: string; - workflowUrl?: string; - submissionCount: number; + success: boolean + error?: string + workflowUrl?: string + submissionCount: number } /** @@ -146,17 +137,17 @@ export async function triggerBulkAddIconWorkflow( success: false, error: "No submission IDs provided", submissionCount: 0, - }; + } } // Join all IDs with commas and trigger a single workflow - const commaSeparatedIds = submissionIds.join(","); - const result = await triggerAddIconWorkflow(authToken, commaSeparatedIds, dryRun); + const commaSeparatedIds = submissionIds.join(",") + const result = await triggerAddIconWorkflow(authToken, commaSeparatedIds, dryRun) return { success: result.success, error: result.error, workflowUrl: result.workflowUrl, submissionCount: submissionIds.length, - }; + } } diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 40a693ca..18bb7d7a 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -10,7 +10,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" import { Textarea } from "@/components/ui/textarea" -import { useApproveSubmission, useAuth, useBulkTriggerWorkflow, useRejectSubmission, useSubmissions, useTriggerWorkflow } from "@/hooks/use-submissions" +import { + useApproveSubmission, + useAuth, + useBulkTriggerWorkflow, + useRejectSubmission, + useSubmissions, + useTriggerWorkflow, +} from "@/hooks/use-submissions" export default function DashboardPage() { // Fetch auth status @@ -230,9 +237,7 @@ export default function DashboardPage() { Approve Submission - - Optional: add a note for the submitter. This will appear in the approval email. - + Optional: add a note for the submitter. This will appear in the approval email.
diff --git a/web/src/components/advanced-icon-submission-form-tanstack.tsx b/web/src/components/advanced-icon-submission-form-tanstack.tsx index e4edb28f..ef2bf0d9 100644 --- a/web/src/components/advanced-icon-submission-form-tanstack.tsx +++ b/web/src/components/advanced-icon-submission-form-tanstack.tsx @@ -327,7 +327,6 @@ export function AdvancedIconSubmissionFormTanStack() { return (
- diff --git a/web/src/components/icon-customizer-inline.tsx b/web/src/components/icon-customizer-inline.tsx index f266e4d6..2400b178 100644 --- a/web/src/components/icon-customizer-inline.tsx +++ b/web/src/components/icon-customizer-inline.tsx @@ -209,41 +209,28 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz -

- This feature extracts and allows you to customize fill and - stroke colors found in the SVG. Colors can be defined in various - ways: as attributes (fill="white"), inline styles - (style="fill:#2396ed"), or within style tags. Each unique color - gets its own color picker for easy customization. + This feature extracts and allows you to customize fill and stroke colors found in the SVG. Colors can be defined in various + ways: as attributes (fill="white"), inline styles (style="fill:#2396ed"), or within style tags. Each unique color gets its + own color picker for easy customization.

-
{originalColors.map((originalColor, index) => { - const currentColor = colorMappings[originalColor] || originalColor; - const hslColor = hexToHsl(currentColor); + const currentColor = colorMappings[originalColor] || originalColor + const hslColor = hexToHsl(currentColor) return (
@@ -253,19 +240,17 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz style={{ backgroundColor: originalColor }} title={`Original: ${originalColor}`} /> - +
{ - const hex = hslToHex(newColor); - handleColorChange(originalColor, hex); + const hex = hslToHex(newColor) + handleColorChange(originalColor, hex) }} />
- ); + ) })}
@@ -282,19 +267,12 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz dangerouslySetInnerHTML={{ __html: customizedSvg }} /> ) : ( -
- Preview loading... -
+
Preview loading...
)}
- @@ -304,5 +282,5 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
- ); + ) } diff --git a/web/src/components/icon-details.tsx b/web/src/components/icon-details.tsx index c3a9893a..72c2198a 100644 --- a/web/src/components/icon-details.tsx +++ b/web/src/components/icon-details.tsx @@ -6,7 +6,7 @@ import { ArrowRight, Check, FileType, Github, Moon, Palette, PaletteIcon, Sun, T import Image from "next/image" import Link from "next/link" import type React from "react" -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react" import { toast } from "sonner" import { IconsGrid } from "@/components/icon-grid" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" @@ -224,7 +224,7 @@ export function IconDetails({ const [copiedUrlKey, setCopiedUrlKey] = useState(null) const [copiedImageKey, setCopiedImageKey] = useState(null) const [isCustomizerOpen, setIsCustomizerOpen] = useState(false) - const [hasGradients, setHasGradients] = useState(null); + const [hasGradients, setHasGradients] = useState(null) const launchConfetti = useCallback((originX?: number, originY?: number) => { if (typeof confetti !== "function") return @@ -543,33 +543,30 @@ export function IconDetails({ useEffect(() => { if (!svgUrl) { - setHasGradients(null); - return; + setHasGradients(null) + return } const checkForGradients = async () => { try { - const response = await fetch(svgUrl); + const response = await fetch(svgUrl) if (!response.ok) { - setHasGradients(null); - return; + setHasGradients(null) + return } - const text = await response.text(); - const hasLinearGradient = /]/i.test(text); - const hasRadialGradient = /]/i.test(text); - setHasGradients(hasLinearGradient || hasRadialGradient); + const text = await response.text() + const hasLinearGradient = /]/i.test(text) + const hasRadialGradient = /]/i.test(text) + setHasGradients(hasLinearGradient || hasRadialGradient) } catch { - setHasGradients(null); + setHasGradients(null) } - }; + } - checkForGradients(); - }, [svgUrl]); + checkForGradients() + }, [svgUrl]) - const canCustomize = - svgUrl !== null && - availableFormats.includes("svg") && - hasGradients === false; + const canCustomize = svgUrl !== null && availableFormats.includes("svg") && hasGradients === false return (
@@ -579,12 +576,8 @@ export function IconDetails({
-

- If you would like to help us expand our collection, you can submit your icons using our submission form or by creating an issue on Github + If you would like to help us expand our collection, you can submit your icons using our submission form or by creating an issue on + Github

)} - {/* GitHub Button (Coming Soon) */} - {/* Divider */} diff --git a/web/src/components/submissions-data-table.tsx b/web/src/components/submissions-data-table.tsx index 1270a647..3647f28e 100644 --- a/web/src/components/submissions-data-table.tsx +++ b/web/src/components/submissions-data-table.tsx @@ -4,12 +4,12 @@ import { type ColumnDef, type ColumnFiltersState, type ExpandedState, - type RowSelectionState, flexRender, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getSortedRowModel, + type RowSelectionState, type SortingState, useReactTable, } from "@tanstack/react-table" @@ -438,12 +438,7 @@ export function SubmissionsDataTable({ - diff --git a/web/src/lib/svg-color-utils.ts b/web/src/lib/svg-color-utils.ts index 4a8d65ff..510d7d9c 100644 --- a/web/src/lib/svg-color-utils.ts +++ b/web/src/lib/svg-color-utils.ts @@ -549,48 +549,45 @@ export function applyColorMappingsToSvg(svg: string, mappings: ColorMapping): st * Forces width/height to 100% for proper scaling within containers * Returns the processed SVG string */ -export function ensureSvgAttributes( - svg: string, - viewBox: string = DEFAULT_VIEWBOX, -): string { +export function ensureSvgAttributes(svg: string, viewBox: string = DEFAULT_VIEWBOX): string { if (!svg || typeof svg !== "string") { - return svg; + return svg } try { - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(svg, "image/svg+xml"); - const svgElement = svgDoc.documentElement; + const parser = new DOMParser() + const svgDoc = parser.parseFromString(svg, "image/svg+xml") + const svgElement = svgDoc.documentElement if (!svgElement) { - return svg; + return svg } // If no viewBox exists, try to create one from width/height attributes if (!svgElement.getAttribute("viewBox")) { - const width = svgElement.getAttribute("width"); - const height = svgElement.getAttribute("height"); + const width = svgElement.getAttribute("width") + const height = svgElement.getAttribute("height") if (width && height) { - const numWidth = parseFloat(width); - const numHeight = parseFloat(height); + const numWidth = parseFloat(width) + const numHeight = parseFloat(height) if (!isNaN(numWidth) && !isNaN(numHeight)) { - svgElement.setAttribute("viewBox", `0 0 ${numWidth} ${numHeight}`); + svgElement.setAttribute("viewBox", `0 0 ${numWidth} ${numHeight}`) } } else { - svgElement.setAttribute("viewBox", viewBox); + svgElement.setAttribute("viewBox", viewBox) } } // Always set width and height to 100% for proper scaling - svgElement.setAttribute("width", "100%"); - svgElement.setAttribute("height", "100%"); + svgElement.setAttribute("width", "100%") + svgElement.setAttribute("height", "100%") - const serializer = new XMLSerializer(); - return serializer.serializeToString(svgElement); + const serializer = new XMLSerializer() + return serializer.serializeToString(svgElement) } catch (error) { - console.error("Error ensuring SVG attributes:", error); - return svg; + console.error("Error ensuring SVG attributes:", error) + return svg } } From 2bfb024eb4a6a62988673ce28a79828d6dc8a3cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:51:30 +0000 Subject: [PATCH 3/4] Add GitHub OAuth setup documentation to README Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- web/README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/web/README.md b/web/README.md index fd9f1259..45528ddc 100644 --- a/web/README.md +++ b/web/README.md @@ -11,6 +11,9 @@ A web application to browse, search, and download icons from the [Dashboard Icon - Copy icon URLs directly to your clipboard - Responsive design that works on mobile, tablet, and desktop - Dark mode support +- **User authentication** - Sign in with email/password or GitHub OAuth +- **Submit icons** - Authenticated users can submit new icons to the collection +- **Admin dashboard** - Admins can approve, reject, and manage icon submissions ## Tech Stack @@ -18,6 +21,8 @@ A web application to browse, search, and download icons from the [Dashboard Icon - **TypeScript v5** - Type-safe JavaScript - **Tailwind CSS** - Utility-first CSS framework - **Shadcn UI** - Reusable components built with Radix UI and Tailwind +- **PocketBase** - Backend for authentication and data storage +- **PostHog** - Product analytics and user tracking ## Project Structure @@ -67,8 +72,31 @@ src/ 3. Create a `.env` file with the following variables: ``` GITHUB_TOKEN=your_github_token + NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090 ``` -4. Start the development server: +4. **Configure GitHub OAuth (Optional):** + + To enable GitHub OAuth login, you need to create a GitHub OAuth App and configure it in PocketBase: + + a. Create a GitHub OAuth App: + - Go to GitHub Settings → Developer settings → OAuth Apps → New OAuth App + - Set Application name: "Dashboard Icons" (or your preferred name) + - Set Homepage URL: `http://localhost:3000` (for development) + - Set Authorization callback URL: `http://localhost:8090/api/oauth2-redirect` + - After creation, note the **Client ID** and generate a **Client Secret** + + b. Configure PocketBase OAuth: + - Start PocketBase: `pnpm run backend:start` + - Open PocketBase admin UI at `http://127.0.0.1:8090/_/` + - Navigate to Settings → Auth providers + - Enable GitHub provider and enter your Client ID and Client Secret + - Save the settings + + c. For production deployment: + - Update the Authorization callback URL to: `https://pb.dashboardicons.com/api/oauth2-redirect` + - Configure the same OAuth settings in your production PocketBase instance + +5. Start the development server: ```bash pnpm dev ``` From 9dbf3a9daef56b0d5ea86cfc2ba3a7f5da7c655d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:54:28 +0000 Subject: [PATCH 4/4] Improve OAuth error handling and UX based on code review Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- web/src/components/login-modal.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/web/src/components/login-modal.tsx b/web/src/components/login-modal.tsx index abb30c34..7f87f486 100644 --- a/web/src/components/login-modal.tsx +++ b/web/src/components/login-modal.tsx @@ -130,7 +130,19 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { resetForm() } catch (err: any) { console.error("GitHub OAuth error:", err) - setError(err?.message || "GitHub authentication failed. Please try again.") + + // Provide specific error messages based on the error type + let errorMessage = "GitHub authentication failed. Please try again." + + if (err?.message?.includes("popup") || err?.message?.includes("blocked")) { + errorMessage = "Popup was blocked. Please allow popups for this site and try again." + } else if (err?.message?.includes("cancelled") || err?.message?.includes("closed")) { + errorMessage = "Authentication was cancelled. Please try again if you want to sign in with GitHub." + } else if (err?.message) { + errorMessage = err.message + } + + setError(errorMessage) } finally { setIsLoading(false) } @@ -208,6 +220,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { value={email} onChange={(e) => setEmail(e.target.value)} className="h-12 text-base" + disabled={isLoading} required /> {isRegister && ( @@ -229,6 +242,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { value={username} onChange={(e) => setUsername(e.target.value)} className="h-12 text-base" + disabled={isLoading} required />

This will be displayed publicly with your submissions

@@ -248,6 +262,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { value={password} onChange={(e) => setPassword(e.target.value)} className="h-12 text-base" + disabled={isLoading} required />
@@ -266,6 +281,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="h-12 text-base" + disabled={isLoading} required />