From be90e727c1e454f5f76c81606856030e59cfbccc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 1 Oct 2025 15:47:23 +0200 Subject: [PATCH] feat(web): add submission details dialog component with review and approval functionality --- web/src/components/submission-details.tsx | 403 ++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 web/src/components/submission-details.tsx diff --git a/web/src/components/submission-details.tsx b/web/src/components/submission-details.tsx new file mode 100644 index 00000000..d6b153af --- /dev/null +++ b/web/src/components/submission-details.tsx @@ -0,0 +1,403 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Button } from "@/components/ui/button" +import { Calendar, User as UserIcon, FileType, Tag, FolderOpen, Palette, ExternalLink, Download, Check, X } from "lucide-react" +import { MagicCard } from "@/components/magicui/magic-card" +import { IconCard } from "@/components/icon-card" +import { formatIconName } from "@/lib/utils" +import type { Icon } from "@/types/icons" +import { pb, type Submission, type User } from "@/lib/pb" +import Link from "next/link" +import Image from "next/image" +import { UserDisplay } from "@/components/user-display" + +// Utility function to get display name with priority: username > email > created_by field +const getDisplayName = (submission: Submission, expandedData?: { created_by: User, approved_by: User }): string => { + // Check if we have expanded user data + if (expandedData && expandedData.created_by) { + const user = expandedData.created_by + + // Priority: username > email + if (user.username) { + return user.username + } + if (user.email) { + return user.email + } + } + + // Fallback to created_by field (could be user ID or username) + return submission.created_by +} + +interface SubmissionDetailsProps { + submission: Submission + isAdmin: boolean + onUserClick?: (userId: string, displayName: string) => void + onApprove?: () => void + onReject?: () => void + isApproving?: boolean + isRejecting?: boolean +} + +export function SubmissionDetails({ submission, isAdmin, onUserClick, onApprove, onReject, isApproving, isRejecting }: SubmissionDetailsProps) { + const expandedData = submission.expand + const displayName = getDisplayName(submission, expandedData) + + // Sanitize extras to ensure we have safe defaults + const sanitizedExtras = { + base: submission.extras?.base || "svg", + aliases: submission.extras?.aliases || [], + categories: submission.extras?.categories || [], + colors: submission.extras?.colors || null, + wordmark: submission.extras?.wordmark || null + } + + const formattedCreated = new Date(submission.created).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + + const formattedUpdated = new Date(submission.updated).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + + // Create a mock Icon object for the IconCard component + const mockIconData: Icon = { + base: sanitizedExtras.base, + aliases: sanitizedExtras.aliases, + categories: sanitizedExtras.categories, + update: { + timestamp: submission.updated, + author: { + id: 1, + name: displayName + } + }, + colors: sanitizedExtras.colors ? { + dark: sanitizedExtras.colors.dark, + light: sanitizedExtras.colors.light + } : undefined, + wordmark: sanitizedExtras.wordmark ? { + dark: sanitizedExtras.wordmark.dark, + light: sanitizedExtras.wordmark.light + } : undefined + } + + const handleDownload = async (url: string, filename: string) => { + try { + const response = await fetch(url) + const blob = await response.blob() + const blobUrl = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = blobUrl + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + setTimeout(() => URL.revokeObjectURL(blobUrl), 100) + } catch (error) { + console.error("Download error:", error) + } + } + + return ( +
+ {/* Left Column - Assets Preview */} +
+ + + + + Assets Preview + + + +
+ {submission.assets.map((asset, index) => ( + +
+
+ {`${submission.name} +
+
+ + +
+
+
+ ))} + {submission.assets.length === 0 && ( +
+ No assets available +
+ )} +
+
+
+
+ + {/* Middle Column - Submission Details */} +
+ + +
+ + + Submission Details + + {(onApprove || onReject) && ( +
+ {onApprove && ( + + )} + {onReject && ( + + )} +
+ )} +
+
+ +
+
+

+ + Icon Name +

+

{formatIconName(submission.name)}

+

Filename: {submission.name}

+
+ +
+

+ + Base Format +

+ + {sanitizedExtras.base} + +
+ + {sanitizedExtras.colors && (Object.keys(sanitizedExtras.colors).length > 0) && ( +
+

+ + Color Variants +

+
+ {sanitizedExtras.colors.dark && ( +
+ Dark: + + {sanitizedExtras.colors.dark} + +
+ )} + {sanitizedExtras.colors.light && ( +
+ Light: + + {sanitizedExtras.colors.light} + +
+ )} +
+
+ )} + + {sanitizedExtras.wordmark && (Object.keys(sanitizedExtras.wordmark).length > 0) && ( +
+

+ + Wordmark Variants +

+
+ {sanitizedExtras.wordmark.dark && ( +
+ Dark: + + {sanitizedExtras.wordmark.dark} + +
+ )} + {sanitizedExtras.wordmark.light && ( +
+ Light: + + {sanitizedExtras.wordmark.light} + +
+ )} +
+
+ )} + +
+
+

+ + Submitted By +

+ +
+ + {submission.approved_by && ( +
+

+ + {submission.status === 'approved' ? 'Approved By' : 'Reviewed By'} +

+ + +
+ )} +
+ +
+
+

+ + Created +

+

{formattedCreated}

+
+ +
+

+ + Last Updated +

+

{formattedUpdated}

+
+
+ + + +
+
+

+ + Categories +

+ {sanitizedExtras.categories.length > 0 ? ( +
+ {sanitizedExtras.categories.map((category) => ( + + {category + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ")} + + ))} +
+ ) : ( +

No categories assigned

+ )} +
+ +
+

+ + Aliases +

+ {sanitizedExtras.aliases.length > 0 ? ( +
+ {sanitizedExtras.aliases.map((alias) => ( + + {alias} + + ))} +
+ ) : ( +

No aliases assigned

+ )} +
+
+ + {isAdmin && ( +
+

Submission ID

+ + {submission.id} + +
+ )} +
+
+
+
+
+ ) +}