diff --git a/package.json b/package.json deleted file mode 100644 index 77d1409c..00000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "@tanstack/react-table": "^8.21.3" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 75c7727f..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,57 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tanstack/react-table': - specifier: ^8.21.3 - version: 8.21.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - -packages: - - '@tanstack/react-table@8.21.3': - resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} - - react-dom@19.1.1: - resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} - peerDependencies: - react: ^19.1.1 - - react@19.1.1: - resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} - engines: {node: '>=0.10.0'} - - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} - -snapshots: - - '@tanstack/react-table@8.21.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@tanstack/table-core': 8.21.3 - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - - '@tanstack/table-core@8.21.3': {} - - react-dom@19.1.1(react@19.1.1): - dependencies: - react: 19.1.1 - scheduler: 0.26.0 - - react@19.1.1: {} - - scheduler@0.26.0: {} diff --git a/web/src/app/actions/submissions.ts b/web/src/app/actions/submissions.ts index 29fbc9f1..7656ea6b 100644 --- a/web/src/app/actions/submissions.ts +++ b/web/src/app/actions/submissions.ts @@ -34,4 +34,3 @@ export async function revalidateAllSubmissions() { return { success: false, error: "Failed to revalidate" } } } - diff --git a/web/src/app/community/page.tsx b/web/src/app/community/page.tsx index dd2436fa..a775a9e8 100644 --- a/web/src/app/community/page.tsx +++ b/web/src/app/community/page.tsx @@ -53,4 +53,3 @@ export default async function CommunityPage() { ) } - diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 3adaa0de..397ead62 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -1,144 +1,130 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { SubmissionsDataTable } from "@/components/submissions-data-table" -import { useAuth, useSubmissions, useApproveSubmission, useRejectSubmission } from "@/hooks/use-submissions" -import { Skeleton } from "@/components/ui/skeleton" -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { AlertCircle, RefreshCw } from "lucide-react" +import { SubmissionsDataTable } from "@/components/submissions-data-table" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { useApproveSubmission, useAuth, useRejectSubmission, useSubmissions } from "@/hooks/use-submissions" export default function DashboardPage() { - // Fetch auth status - const { data: auth, isLoading: authLoading } = useAuth() - - // Fetch submissions - const { - data: submissions = [], - isLoading: submissionsLoading, - error: submissionsError, - refetch - } = useSubmissions() - - // Mutations - const approveMutation = useApproveSubmission() - const rejectMutation = useRejectSubmission() - - const isLoading = authLoading || submissionsLoading - const isAuthenticated = auth?.isAuthenticated ?? false - const isAdmin = auth?.isAdmin ?? false - const currentUserId = auth?.userId ?? '' + // Fetch auth status + const { data: auth, isLoading: authLoading } = useAuth() - const handleApprove = (submissionId: string) => { - approveMutation.mutate(submissionId) - } + // Fetch submissions + const { data: submissions = [], isLoading: submissionsLoading, error: submissionsError, refetch } = useSubmissions() - const handleReject = (submissionId: string) => { - rejectMutation.mutate(submissionId) - } + // Mutations + const approveMutation = useApproveSubmission() + const rejectMutation = useRejectSubmission() - // Not authenticated - if (!authLoading && !isAuthenticated) { - return ( -
- - - Access Denied - You need to be logged in to access the dashboard. - - -
- ) - } + const isLoading = authLoading || submissionsLoading + const isAuthenticated = auth?.isAuthenticated ?? false + const isAdmin = auth?.isAdmin ?? false + const currentUserId = auth?.userId ?? "" - // Loading state - if (isLoading) { - return ( -
- - -
- - -
-
- -
- -
- - - -
-
-
-
-
- ) - } + const handleApprove = (submissionId: string) => { + approveMutation.mutate(submissionId) + } - // Error state - if (submissionsError) { - return ( -
- - - Submissions Dashboard - - {isAdmin - ? "Review and manage all icon submissions." - : "View your icon submissions and track their status." - } - - - - - - Error loading submissions - - Failed to load submissions. Please try again. - - - - - -
- ) - } + const handleReject = (submissionId: string) => { + rejectMutation.mutate(submissionId) + } - // Success state - return ( -
- - - Submissions Dashboard - - {isAdmin - ? "Review and manage all icon submissions. Click on a row to see details." - : "View your icon submissions and track their status." - } - - - - - - -
- ) + // Not authenticated + if (!authLoading && !isAuthenticated) { + return ( +
+ + + Access Denied + You need to be logged in to access the dashboard. + + +
+ ) + } + + // Loading state + if (isLoading) { + return ( +
+ + +
+ + +
+
+ +
+ +
+ + + +
+
+
+
+
+ ) + } + + // Error state + if (submissionsError) { + return ( +
+ + + Submissions Dashboard + + {isAdmin ? "Review and manage all icon submissions." : "View your icon submissions and track their status."} + + + + + + Error loading submissions + + Failed to load submissions. Please try again. + + + + + +
+ ) + } + + // Success state + return ( +
+ + + Submissions Dashboard + + {isAdmin + ? "Review and manage all icon submissions. Click on a row to see details." + : "View your icon submissions and track their status."} + + + + + + +
+ ) } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5ad82a62..dfb89952 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -8,8 +8,8 @@ import { PostHogProvider } from "@/components/PostHogProvider" import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants" import { getTotalIcons } from "@/lib/api" import "./globals.css" -import { ThemeProvider } from "./theme-provider" import { Providers } from "@/components/providers" +import { ThemeProvider } from "./theme-provider" const inter = Inter({ variable: "--font-inter", diff --git a/web/src/components/community-icon-search.tsx b/web/src/components/community-icon-search.tsx index 5eb6c44c..1cbccfea 100644 --- a/web/src/components/community-icon-search.tsx +++ b/web/src/components/community-icon-search.tsx @@ -115,19 +115,19 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) { categories: selectedCategories, sort: sortOption, }) as IconWithStatus[] - + return result }, [icons, debouncedQuery, selectedCategories, sortOption]) const groupedIcons = useMemo(() => { const statusPriority = { pending: 0, approved: 1, rejected: 2, added_to_collection: 3 } - + const groups: Record = {} - + for (const icon of filteredIcons) { const iconWithStatus = icon as IconWithStatus - const status = iconWithStatus.status || 'pending' - + const status = iconWithStatus.status || "pending" + if (!groups[status]) { groups[status] = [] } @@ -136,8 +136,7 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) { return Object.entries(groups) .sort(([a], [b]) => { - return (statusPriority[a as keyof typeof statusPriority] ?? 999) - - (statusPriority[b as keyof typeof statusPriority] ?? 999) + return (statusPriority[a as keyof typeof statusPriority] ?? 999) - (statusPriority[b as keyof typeof statusPriority] ?? 999) }) .map(([status, items]) => ({ status, items })) }, [filteredIcons]) @@ -410,7 +409,7 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) { {getStatusDisplayName(status)} - {items.length} {items.length === 1 ? 'icon' : 'icons'} + {items.length} {items.length === 1 ? "icon" : "icons"} @@ -425,4 +424,3 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) { ) } - diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index bfc91762..dab66a39 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -1,6 +1,6 @@ "use client" -import { Github, LogOut, PlusCircle, Search, Star, LayoutDashboard } from "lucide-react" +import { Github, LayoutDashboard, LogOut, PlusCircle, Search, Star } from "lucide-react" import Link from "next/link" import { useEffect, useState } from "react" import { IconSubmissionForm } from "@/components/icon-submission-form" @@ -14,12 +14,7 @@ import { CommandMenu } from "./command-menu" import { HeaderNav } from "./header-nav" import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar" import { Button } from "./ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./ui/dropdown-menu" +import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" interface UserData { @@ -119,13 +114,8 @@ export function Header() {
- - - Dashboard Icons - + + Dashboard Icons
@@ -134,11 +124,7 @@ export function Header() {
{/* Desktop search button */}
- @@ -201,11 +183,7 @@ export function Header() { -
- + {isLoggedIn && userData && ( @@ -234,13 +212,8 @@ export function Header() { size="icon" > - - - {userData.username.slice(0, 2).toUpperCase()} - + + {userData.username.slice(0, 2).toUpperCase()} User menu @@ -249,29 +222,18 @@ export function Header() {
- - - {userData.username.slice(0, 2).toUpperCase()} - + + {userData.username.slice(0, 2).toUpperCase()}

{userData.username}

-

- {userData.email} -

+

{userData.email}

-
{/* Single instance of CommandMenu */} - {isLoaded && ( - - )} + {isLoaded && } {/* Login Modal */} diff --git a/web/src/components/icon-card.tsx b/web/src/components/icon-card.tsx index bc7af1b3..68dbb0ec 100644 --- a/web/src/components/icon-card.tsx +++ b/web/src/components/icon-card.tsx @@ -7,14 +7,12 @@ import type { Icon } from "@/types/icons" export function IconCard({ name, data: iconData, matchedAlias }: { name: string; data: Icon; matchedAlias?: string }) { const formatedIconName = formatIconName(name) - + const isCommunityIcon = iconData.base.startsWith("http") - const imageUrl = isCommunityIcon - ? iconData.base - : `${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}` - + const imageUrl = isCommunityIcon ? iconData.base : `${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}` + const linkHref = isCommunityIcon ? `/community/${name}` : `/icons/${name}` - + return ( diff --git a/web/src/components/login-modal.tsx b/web/src/components/login-modal.tsx index caee4fd5..4706a5fc 100644 --- a/web/src/components/login-modal.tsx +++ b/web/src/components/login-modal.tsx @@ -4,13 +4,7 @@ import { Github } from "lucide-react" import type React from "react" import { useRef, useState } from "react" import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" @@ -30,7 +24,6 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { const [error, setError] = useState("") const [isLoading, setIsLoading] = useState(false) const emailRef = useRef(null) - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError("") @@ -95,21 +88,21 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { - - {isRegister ? "Create account" : "Sign in"} - + {isRegister ? "Create account" : "Sign in"} - {isRegister - ? "Enter your details to create an account" - : "Enter your credentials to continue"} + {isRegister ? "Enter your details to create an account" : "Enter your credentials to continue"} - +
{error && (
- + {error}
@@ -135,7 +128,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
setEmail(e.target.value)} aria-invalid={error ? "true" : "false"} @@ -153,9 +146,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { required /> {isRegister && ( -

- Used only to send you updates about your submissions -

+

Used only to send you updates about your submissions

)}
@@ -177,9 +168,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) { className="h-11 text-base" required /> -

- This will be displayed publicly with your submissions -

+

This will be displayed publicly with your submissions

)} @@ -225,16 +214,16 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
- - -
-
- - ))} - {submission.assets.length === 0 && ( -
- No assets available -
- )} -
- - - + // 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, + } - {/* Middle Column - Submission Details */} -
- - -
- - - Submission Details - - {(onApprove || onReject) && ( -
- {onApprove && ( - - )} - {onReject && ( - - )} -
- )} -
-
- -
-
-

- - Icon Name -

-

{formatIconName(submission.name)}

-

Filename: {submission.name}

-
+ 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) + } + } -
-

- - Base Format -

- - {sanitizedExtras.base} - -
+ return ( +
+ {/* Left Column - Assets Preview */} +
+ + + + + Assets Preview + + + +
+ {submission.assets.map((asset, index) => ( + +
+
+ {`${submission.name} +
+
+ + +
+
+
+ ))} + {submission.assets.length === 0 &&
No assets available
} +
+
+
+
- {sanitizedExtras.colors && (Object.keys(sanitizedExtras.colors).length > 0) && ( -
-

- - Color Variants -

-
- {sanitizedExtras.colors.dark && ( -
- Dark: - - {sanitizedExtras.colors.dark} - -
- )} - {sanitizedExtras.colors.light && ( -
- Light: - - {sanitizedExtras.colors.light} - -
- )} -
-
- )} + {/* Middle Column - Submission Details */} +
+ + +
+ + + Submission Details + + {(onApprove || onReject) && ( +
+ {onApprove && ( + + )} + {onReject && ( + + )} +
+ )} +
+
+ +
+
+

+ + Icon Name +

+

{formatIconName(submission.name)}

+

Filename: {submission.name}

+
- {sanitizedExtras.wordmark && (Object.keys(sanitizedExtras.wordmark).length > 0) && ( -
-

- - Wordmark Variants -

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

+ + Base Format +

+ + {sanitizedExtras.base} + +
-
-
-

- - Submitted By -

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

+ + Color Variants +

+
+ {sanitizedExtras.colors.dark && ( +
+ Dark: + {sanitizedExtras.colors.dark} +
+ )} + {sanitizedExtras.colors.light && ( +
+ Light: + {sanitizedExtras.colors.light} +
+ )} +
+
+ )} - {submission.approved_by && ( -
-

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

+ {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 +

+ +
-
-
-

- - Created -

-

{formattedCreated}

-
+ {submission.approved_by && ( +
+

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

-
-

- - Last Updated -

-

{formattedUpdated}

-
-
+ +
+ )} +
- +
+
+

+ + Created +

+

{formattedCreated}

+
-
-
-

- - Categories -

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

No categories assigned

- )} -
+
+

+ + Last Updated +

+

{formattedUpdated}

+
+
-
-

- - Aliases -

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

No aliases assigned

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

Submission ID

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

+ + 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} +
+ )} +
+
+
+
+ + ) } diff --git a/web/src/components/submissions-data-table.tsx b/web/src/components/submissions-data-table.tsx index dd0b12ff..d8ef10b3 100644 --- a/web/src/components/submissions-data-table.tsx +++ b/web/src/components/submissions-data-table.tsx @@ -1,443 +1,448 @@ "use client" -import * as React from "react" import { - type ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getSortedRowModel, - type SortingState, - type ExpandedState, - getExpandedRowModel, - getFilteredRowModel, - type ColumnFiltersState, + type ColumnDef, + type ColumnFiltersState, + type ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getSortedRowModel, + type SortingState, + useReactTable, } from "@tanstack/react-table" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import dayjs from "dayjs" +import relativeTime from "dayjs/plugin/relativeTime" +import { Check, ChevronDown, ChevronRight, Filter, ImageIcon, Search, SortDesc, X } from "lucide-react" +import * as React from "react" +import { SubmissionDetails } from "@/components/submission-details" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { ChevronDown, ChevronRight, ImageIcon, Check, X, Search, Filter, SortDesc } from "lucide-react" -import { SubmissionDetails } from "@/components/submission-details" -import { cn } from "@/lib/utils" -import { pb, type Submission } from "@/lib/pb" -import dayjs from "dayjs" -import relativeTime from "dayjs/plugin/relativeTime" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { UserDisplay } from "@/components/user-display" +import { pb, type Submission } from "@/lib/pb" +import { cn } from "@/lib/utils" // Initialize dayjs relative time plugin dayjs.extend(relativeTime) // Utility function to get display name with priority: username > email > created_by field const getDisplayName = (submission: Submission, expandedData?: any): string => { - console.log('🏷️ Getting display name for submission:', submission.id) - console.log('👤 created_by field:', submission.created_by) - console.log('🔗 expanded data:', expandedData) - - // Check if we have expanded user data - if (expandedData && expandedData.created_by) { - const user = expandedData.created_by - console.log('📋 User data from expand:', user) - - // Priority: username > email - if (user.username) { - console.log('✅ Using username:', user.username) - return user.username - } - if (user.email) { - console.log('✅ Using email:', user.email) - return user.email - } - } - - // Fallback to created_by field (could be user ID or username) - console.log('⚠️ Fallback to created_by field:', submission.created_by) - return submission.created_by + console.log("🏷️ Getting display name for submission:", submission.id) + console.log("👤 created_by field:", submission.created_by) + console.log("🔗 expanded data:", expandedData) + + // Check if we have expanded user data + if (expandedData && expandedData.created_by) { + const user = expandedData.created_by + console.log("📋 User data from expand:", user) + + // Priority: username > email + if (user.username) { + console.log("✅ Using username:", user.username) + return user.username + } + if (user.email) { + console.log("✅ Using email:", user.email) + return user.email + } + } + + // Fallback to created_by field (could be user ID or username) + console.log("⚠️ Fallback to created_by field:", submission.created_by) + return submission.created_by } interface SubmissionsDataTableProps { - data: Submission[] - isAdmin: boolean - currentUserId: string - onApprove: (id: string) => void - onReject: (id: string) => void - isApproving?: boolean - isRejecting?: boolean + data: Submission[] + isAdmin: boolean + currentUserId: string + onApprove: (id: string) => void + onReject: (id: string) => void + isApproving?: boolean + isRejecting?: boolean } // Group submissions by status with priority order const groupAndSortSubmissions = (submissions: Submission[]): Submission[] => { - const statusPriority = { pending: 0, approved: 1, added_to_collection: 2, rejected: 3 } - - return [...submissions].sort((a, b) => { - // First, sort by status priority - const statusDiff = statusPriority[a.status] - statusPriority[b.status] - if (statusDiff !== 0) return statusDiff - - // Within same status, sort by updated time (most recent first) - return new Date(b.updated).getTime() - new Date(a.updated).getTime() - }) + const statusPriority = { pending: 0, approved: 1, added_to_collection: 2, rejected: 3 } + + return [...submissions].sort((a, b) => { + // First, sort by status priority + const statusDiff = statusPriority[a.status] - statusPriority[b.status] + if (statusDiff !== 0) return statusDiff + + // Within same status, sort by updated time (most recent first) + return new Date(b.updated).getTime() - new Date(a.updated).getTime() + }) } const getStatusColor = (status: Submission["status"]) => { - switch (status) { - case "approved": - return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20" - case "rejected": + switch (status) { + case "approved": + return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20" + case "rejected": return "bg-red-500/10 text-red-500 border-red-500/20" - case "pending": - return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" - case "added_to_collection": - return "bg-green-500/10 text-green-500 border-green-500/20" - default: - return "bg-gray-500/10 text-gray-500 border-gray-500/20" - } + case "pending": + return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" + case "added_to_collection": + return "bg-green-500/10 text-green-500 border-green-500/20" + default: + return "bg-gray-500/10 text-gray-500 border-gray-500/20" + } } const getStatusDisplayName = (status: Submission["status"]) => { - switch (status) { - case "pending": - return "Pending" - case "approved": - return "Approved" - case "rejected": - return "Rejected" - case "added_to_collection": - return "Added to Collection" - default: - return status - } + switch (status) { + case "pending": + return "Pending" + case "approved": + return "Approved" + case "rejected": + return "Rejected" + case "added_to_collection": + return "Added to Collection" + default: + return status + } } -export function SubmissionsDataTable({ data, isAdmin, currentUserId, onApprove, onReject, isApproving, isRejecting }: SubmissionsDataTableProps) { - const [sorting, setSorting] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) - const [columnFilters, setColumnFilters] = React.useState([]) - const [globalFilter, setGlobalFilter] = React.useState("") - const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null) - - // Handle row expansion - only one row can be expanded at a time - const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => { - setExpanded(isExpanded ? {} : { [rowId]: true }) - }, []) - - // Group and sort data by status and updated time - const groupedData = React.useMemo(() => { - return groupAndSortSubmissions(data) - }, [data]) - - // Handle user filter - filter by user ID but display username - const handleUserFilter = React.useCallback((userId: string, displayName: string) => { - if (userFilter?.userId === userId) { - setUserFilter(null) - setColumnFilters(prev => prev.filter(filter => filter.id !== "created_by")) - } else { - setUserFilter({ userId, displayName }) - setColumnFilters(prev => [ - ...prev.filter(filter => filter.id !== "created_by"), - { id: "created_by", value: userId } - ]) - } - }, [userFilter]) +export function SubmissionsDataTable({ + data, + isAdmin, + currentUserId, + onApprove, + onReject, + isApproving, + isRejecting, +}: SubmissionsDataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [expanded, setExpanded] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState([]) + const [globalFilter, setGlobalFilter] = React.useState("") + const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null) - const columns: ColumnDef[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => { - return ( - - ) - }, - }, - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) =>
{row.getValue("name")}
, - }, - { - accessorKey: "status", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) => { - const status = row.getValue("status") as Submission["status"] - return ( - - {getStatusDisplayName(status)} - - ) - }, - }, - { - accessorKey: "created_by", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) => { - const submission = row.original - const expandedData = (submission as any).expand - const displayName = getDisplayName(submission, expandedData) - const userId = submission.created_by - - return ( -
- - {userFilter?.userId === userId && ( - - )} -
- ) - }, - }, - { - accessorKey: "updated", - header: ({ column }) => { - return ( - - ) - }, - cell: ({ row }) => { - const date = row.getValue("updated") as string - return ( -
- {dayjs(date).fromNow()} -
- ) - }, - }, - { - accessorKey: "assets", - header: "Preview", - cell: ({ row }) => { - const assets = row.getValue("assets") as string[] - const name = row.getValue("name") as string - if (assets.length > 0) { - return ( -
- {name} -
- ) - } - return ( -
- -
- ) - }, - }, - ] + // Handle row expansion - only one row can be expanded at a time + const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => { + setExpanded(isExpanded ? {} : { [rowId]: true }) + }, []) - const table = useReactTable({ - data: groupedData, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getExpandedRowModel: getExpandedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - onSortingChange: setSorting, - onExpandedChange: setExpanded, - onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, - state: { - sorting, - expanded, - columnFilters, - globalFilter, - }, - getRowCanExpand: () => true, - globalFilterFn: (row, columnId, value) => { - const searchValue = value.toLowerCase() - const name = row.getValue("name") as string - const status = row.getValue("status") as string - const submission = row.original - const expandedData = (submission as any).expand - const displayName = getDisplayName(submission, expandedData) - - return ( - name.toLowerCase().includes(searchValue) || - status.toLowerCase().includes(searchValue) || - displayName.toLowerCase().includes(searchValue) - ) - }, - }) + // Group and sort data by status and updated time + const groupedData = React.useMemo(() => { + return groupAndSortSubmissions(data) + }, [data]) - return ( -
- {/* Search and Filters */} -
-
- - setGlobalFilter(String(event.target.value))} - className="pl-10" - /> -
- - {userFilter && ( -
- - - User: {userFilter.displayName} - - -
- )} -
+ // Handle user filter - filter by user ID but display username + const handleUserFilter = React.useCallback( + (userId: string, displayName: string) => { + if (userFilter?.userId === userId) { + setUserFilter(null) + setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by")) + } else { + setUserFilter({ userId, displayName }) + setColumnFilters((prev) => [...prev.filter((filter) => filter.id !== "created_by"), { id: "created_by", value: userId }]) + } + }, + [userFilter], + ) - {/* Table */} -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - (() => { - let lastStatus: string | null = null - return table.getRowModel().rows.map((row, index) => { - const currentStatus = row.original.status - const showStatusHeader = currentStatus !== lastStatus - lastStatus = currentStatus - - return ( - - {showStatusHeader && ( - - -
- - {getStatusDisplayName(currentStatus)} - - - {table.getRowModel().rows.filter(r => r.original.status === currentStatus).length} - {table.getRowModel().rows.filter(r => r.original.status === currentStatus).length === 1 ? ' submission' : ' submissions'} - -
-
-
- )} - handleRowToggle(row.id, row.getIsExpanded())} - > - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - {row.getIsExpanded() && ( - - - onApprove(row.original.id) : undefined} - onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined} - isApproving={isApproving} - isRejecting={isRejecting} - /> - - - )} -
- ) - }) - })() - ) : ( - - - {globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"} - - - )} -
-
-
-
- ) + const columns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return ( + + ) + }, + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) =>
{row.getValue("name")}
, + }, + { + accessorKey: "status", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const status = row.getValue("status") as Submission["status"] + return ( + + {getStatusDisplayName(status)} + + ) + }, + }, + { + accessorKey: "created_by", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const submission = row.original + const expandedData = (submission as any).expand + const displayName = getDisplayName(submission, expandedData) + const userId = submission.created_by + + return ( +
+ + {userFilter?.userId === userId && } +
+ ) + }, + }, + { + accessorKey: "updated", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const date = row.getValue("updated") as string + return ( +
+ {dayjs(date).fromNow()} +
+ ) + }, + }, + { + accessorKey: "assets", + header: "Preview", + cell: ({ row }) => { + const assets = row.getValue("assets") as string[] + const name = row.getValue("name") as string + if (assets.length > 0) { + return ( +
+ {name} +
+ ) + } + return ( +
+ +
+ ) + }, + }, + ] + + const table = useReactTable({ + data: groupedData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onExpandedChange: setExpanded, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + state: { + sorting, + expanded, + columnFilters, + globalFilter, + }, + getRowCanExpand: () => true, + globalFilterFn: (row, columnId, value) => { + const searchValue = value.toLowerCase() + const name = row.getValue("name") as string + const status = row.getValue("status") as string + const submission = row.original + const expandedData = (submission as any).expand + const displayName = getDisplayName(submission, expandedData) + + return ( + name.toLowerCase().includes(searchValue) || + status.toLowerCase().includes(searchValue) || + displayName.toLowerCase().includes(searchValue) + ) + }, + }) + + return ( +
+ {/* Search and Filters */} +
+
+ + setGlobalFilter(String(event.target.value))} + className="pl-10" + /> +
+ + {userFilter && ( +
+ + + User: {userFilter.displayName} + + +
+ )} +
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + (() => { + let lastStatus: string | null = null + return table.getRowModel().rows.map((row, index) => { + const currentStatus = row.original.status + const showStatusHeader = currentStatus !== lastStatus + lastStatus = currentStatus + + return ( + + {showStatusHeader && ( + + +
+ + {getStatusDisplayName(currentStatus)} + + + {table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length} + {table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length === 1 + ? " submission" + : " submissions"} + +
+
+
+ )} + handleRowToggle(row.id, row.getIsExpanded())} + > + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + {row.getIsExpanded() && ( + + + onApprove(row.original.id) : undefined} + onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined} + isApproving={isApproving} + isRejecting={isRejecting} + /> + + + )} +
+ ) + }) + })() + ) : ( + + + {globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"} + + + )} +
+
+
+
+ ) } diff --git a/web/src/components/user-button.tsx b/web/src/components/user-button.tsx index a6259441..84646836 100644 --- a/web/src/components/user-button.tsx +++ b/web/src/components/user-button.tsx @@ -1,39 +1,30 @@ -"use client"; +"use client" -import { Github, LogOut, User, LayoutDashboard } from "lucide-react"; -import type React from "react"; -import { useState } from "react"; -import Link from "next/link"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { pb } from "@/lib/pb"; +import { Github, LayoutDashboard, LogOut, User } from "lucide-react" +import Link from "next/link" +import type React from "react" +import { useState } from "react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { pb } from "@/lib/pb" interface UserData { - username: string; - email: string; - avatar?: string; + username: string + email: string + avatar?: string } interface UserButtonProps { - asChild?: boolean; - isLoggedIn?: boolean; - userData?: UserData; + asChild?: boolean + isLoggedIn?: boolean + userData?: UserData } -export function UserButton({ - asChild, - isLoggedIn = false, - userData, -}: UserButtonProps) { +export function UserButton({ asChild, isLoggedIn = false, userData }: UserButtonProps) { return ( - ); + ) } interface UserMenuProps { - userData: UserData; - onSignOut: () => void; + userData: UserData + onSignOut: () => void } export function UserMenu({ userData, onSignOut }: UserMenuProps) { @@ -70,29 +56,18 @@ export function UserMenu({ userData, onSignOut }: UserMenuProps) {
- - - {userData.username.slice(0, 2).toUpperCase()} - + + {userData.username.slice(0, 2).toUpperCase()}

{userData.username}

-

- {userData.email} -

+

{userData.email}

-
- ); + ) } interface LoginPopupProps { - trigger?: React.ReactNode; - isLoggedIn?: boolean; - userData?: UserData; - onSignOut?: () => void; + trigger?: React.ReactNode + isLoggedIn?: boolean + userData?: UserData + onSignOut?: () => void } -export function LoginPopup({ - trigger, - isLoggedIn = false, - userData, - onSignOut, -}: LoginPopupProps) { - const [open, setOpen] = useState(false); - const [isRegister, setIsRegister] = useState(false); - const [email, setEmail] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(""); - const [isLoading, setIsLoading] = useState(false); +export function LoginPopup({ trigger, isLoggedIn = false, userData, onSignOut }: LoginPopupProps) { + const [open, setOpen] = useState(false) + const [isRegister, setIsRegister] = useState(false) + const [email, setEmail] = useState("") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setIsLoading(true); + e.preventDefault() + setError("") + setIsLoading(true) try { if (isRegister) { if (password !== confirmPassword) { - setError("Passwords do not match"); - setIsLoading(false); - return; + setError("Passwords do not match") + setIsLoading(false) + return } if (!username.trim()) { - setError("Username is required"); - setIsLoading(false); - return; + setError("Username is required") + setIsLoading(false) + return } if (!email.trim()) { - setError("Email is required"); - setIsLoading(false); - return; + setError("Email is required") + setIsLoading(false) + return } - await pb.collection('users').create({ + await pb.collection("users").create({ username: username.trim(), email: email.trim(), password, passwordConfirm: confirmPassword, - }); + }) - await pb.collection('users').authWithPassword(email, password); + await pb.collection("users").authWithPassword(email, password) } else { // For login, use email as the identifier - await pb.collection('users').authWithPassword(email, password); + await pb.collection("users").authWithPassword(email, password) } - setOpen(false); - setEmail(""); - setUsername(""); - setPassword(""); - setConfirmPassword(""); + setOpen(false) + setEmail("") + setUsername("") + setPassword("") + setConfirmPassword("") } catch (err: any) { - console.error('Auth error:', err); - setError(err?.message || "Authentication failed. Please try again."); + console.error("Auth error:", err) + setError(err?.message || "Authentication failed. Please try again.") } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } const toggleMode = () => { - setIsRegister(!isRegister); - setEmail(""); - setUsername(""); - setPassword(""); - setConfirmPassword(""); - setError(""); - }; + setIsRegister(!isRegister) + setEmail("") + setUsername("") + setPassword("") + setConfirmPassword("") + setError("") + } const handleSignOut = () => { - setOpen(false); + setOpen(false) // Wait for dropdown close animation before updating parent state setTimeout(() => { - onSignOut?.(); - }, 150); - }; + onSignOut?.() + }, 150) + } return ( @@ -214,19 +183,11 @@ export function LoginPopup({ ) : (
-

- {isRegister ? "Create account" : "Sign in"} -

+

{isRegister ? "Create account" : "Sign in"}

- {isRegister - ? "Enter your details to create an account" - : "Enter your credentials to continue"} + {isRegister ? "Enter your details to create an account" : "Enter your credentials to continue"}

- {error && ( -
- {error} -
- )} + {error &&
{error}
}
setEmail(e.target.value)} required /> - {isRegister && ( -

- Used only to send you updates about your submissions -

- )} + {isRegister &&

Used only to send you updates about your submissions

}
{isRegister && ( @@ -281,9 +238,7 @@ export function LoginPopup({ onChange={(e) => setUsername(e.target.value)} required /> -

- This will be displayed publicly with your submissions -

+

This will be displayed publicly with your submissions

)} @@ -322,7 +277,7 @@ export function LoginPopup({