mirror of
https://github.com/homarr-labs/dashboard-icons.git
synced 2026-01-12 16:25:38 +08:00
Merge pull request #2785 from homarr-labs/copilot/add-github-oauth-login
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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<TriggerWorkflowResult> {
|
||||
export async function triggerAddIconWorkflow(authToken: string, submissionIds: string, dryRun = false): Promise<TriggerWorkflowResult> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approve Submission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Optional: add a note for the submitter. This will appear in the approval email.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Optional: add a note for the submitter. This will appear in the approval email.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -327,7 +327,6 @@ export function AdvancedIconSubmissionFormTanStack() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent className="bg-background">
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -209,41 +209,28 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
|
||||
<Label className="text-sm font-semibold">Customize Colors</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
aria-label="Learn more about color customization"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" aria-label="Learn more about color customization">
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
id="close-customizer"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Button id="close-customizer" variant="ghost" size="sm" onClick={onClose} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{originalColors.map((originalColor, index) => {
|
||||
const currentColor = colorMappings[originalColor] || originalColor;
|
||||
const hslColor = hexToHsl(currentColor);
|
||||
const currentColor = colorMappings[originalColor] || originalColor
|
||||
const hslColor = hexToHsl(currentColor)
|
||||
|
||||
return (
|
||||
<div key={originalColor} className="space-y-2">
|
||||
@@ -253,19 +240,17 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
|
||||
style={{ backgroundColor: originalColor }}
|
||||
title={`Original: ${originalColor}`}
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Color {index + 1}
|
||||
</Label>
|
||||
<Label className="text-xs text-muted-foreground">Color {index + 1}</Label>
|
||||
</div>
|
||||
<ColorPicker
|
||||
color={hslColor}
|
||||
onChange={(newColor) => {
|
||||
const hex = hslToHex(newColor);
|
||||
handleColorChange(originalColor, hex);
|
||||
const hex = hslToHex(newColor)
|
||||
handleColorChange(originalColor, hex)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -282,19 +267,12 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
|
||||
dangerouslySetInnerHTML={{ __html: customizedSvg }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Preview loading...
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">Preview loading...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={handleCopySvg}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<Button onClick={handleCopySvg} variant="outline" className="w-full" size="sm">
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
@@ -304,5 +282,5 @@ export function IconCustomizerInline({ svgUrl, iconName, onClose }: IconCustomiz
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [copiedImageKey, setCopiedImageKey] = useState<string | null>(null)
|
||||
const [isCustomizerOpen, setIsCustomizerOpen] = useState(false)
|
||||
const [hasGradients, setHasGradients] = useState<boolean | null>(null);
|
||||
const [hasGradients, setHasGradients] = useState<boolean | null>(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 = /<linearGradient[\s\/>]/i.test(text);
|
||||
const hasRadialGradient = /<radialGradient[\s\/>]/i.test(text);
|
||||
setHasGradients(hasLinearGradient || hasRadialGradient);
|
||||
const text = await response.text()
|
||||
const hasLinearGradient = /<linearGradient[\s/>]/i.test(text)
|
||||
const hasRadialGradient = /<radialGradient[\s/>]/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 (
|
||||
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
||||
@@ -579,12 +576,8 @@ export function IconDetails({
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col items-center bg-background">
|
||||
<div className="relative w-32 h-32 rounded-xl ring-1 ring-white/5 dark:ring-white/10 bg-primary/15 dark:bg-secondary/10 overflow-hidden flex items-center justify-center p-3">
|
||||
<Image
|
||||
src={
|
||||
isCommunityIcon && mainIconUrl
|
||||
? mainIconUrl
|
||||
: `${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`
|
||||
}
|
||||
<Image
|
||||
src={isCommunityIcon && mainIconUrl ? mainIconUrl : `${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
||||
priority
|
||||
width={96}
|
||||
height={96}
|
||||
|
||||
@@ -106,7 +106,8 @@ export function IconSubmissionContent() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
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
|
||||
</p>
|
||||
<Button variant="link" asChild className="text-primary">
|
||||
<Link href="/submit">Submit using the submission form →</Link>
|
||||
|
||||
@@ -106,6 +106,48 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
setTimeout(() => emailRef.current?.focus(), 100)
|
||||
}
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
setError("")
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Authenticate with GitHub OAuth2 using PocketBase's popup-based flow
|
||||
await pb.collection("users").authWithOAuth2({
|
||||
provider: "github",
|
||||
})
|
||||
|
||||
// Identify user immediately after successful authentication
|
||||
// This follows PostHog best practice of calling identify as soon as possible
|
||||
identifyUserInPostHog(posthog)
|
||||
|
||||
// Track OAuth login event
|
||||
posthog?.capture("user_oauth_login", {
|
||||
provider: "github",
|
||||
})
|
||||
|
||||
// Success
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (err: any) {
|
||||
console.error("GitHub OAuth error:", err)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-lg bg-background border shadow-2xl">
|
||||
@@ -132,11 +174,25 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitHub Button (Coming Soon) */}
|
||||
<Button type="button" variant="outline" className="w-full h-12 text-base font-medium cursor-not-allowed opacity-50" disabled>
|
||||
<Github className="h-5 w-5 mr-2" />
|
||||
Continue with GitHub
|
||||
<span className="ml-2 text-xs text-muted-foreground">(Coming soon)</span>
|
||||
{/* GitHub Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-12 text-base font-medium"
|
||||
onClick={handleGitHubLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Connecting to GitHub...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Github className="h-5 w-5 mr-2" />
|
||||
Continue with GitHub
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
@@ -164,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 && (
|
||||
@@ -185,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
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">This will be displayed publicly with your submissions</p>
|
||||
@@ -204,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
|
||||
/>
|
||||
</div>
|
||||
@@ -222,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
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<Button variant="ghost" size="sm" onClick={() => setRowSelection({})}>
|
||||
Clear selection
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBulkTrigger}
|
||||
disabled={isBulkTriggeringWorkflow}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Button size="sm" onClick={handleBulkTrigger} disabled={isBulkTriggeringWorkflow} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
{isBulkTriggeringWorkflow ? "Triggering..." : `Trigger All (${selectedSubmissionIds.length})`}
|
||||
</Button>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user