format code + change env

This commit is contained in:
Thomas Camlong
2025-10-01 19:01:31 +02:00
parent 0a4a4a78f4
commit 49aab75953
19 changed files with 1282 additions and 1468 deletions

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"@tanstack/react-table": "^8.21.3"
}
}

57
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -34,4 +34,3 @@ export async function revalidateAllSubmissions() {
return { success: false, error: "Failed to revalidate" } return { success: false, error: "Failed to revalidate" }
} }
} }

View File

@@ -53,4 +53,3 @@ export default async function CommunityPage() {
</div> </div>
) )
} }

View File

@@ -1,144 +1,130 @@
"use client" "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 { 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 { 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() { export default function DashboardPage() {
// Fetch auth status // Fetch auth status
const { data: auth, isLoading: authLoading } = useAuth() 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 ?? ''
const handleApprove = (submissionId: string) => { // Fetch submissions
approveMutation.mutate(submissionId) const { data: submissions = [], isLoading: submissionsLoading, error: submissionsError, refetch } = useSubmissions()
}
const handleReject = (submissionId: string) => { // Mutations
rejectMutation.mutate(submissionId) const approveMutation = useApproveSubmission()
} const rejectMutation = useRejectSubmission()
// Not authenticated const isLoading = authLoading || submissionsLoading
if (!authLoading && !isAuthenticated) { const isAuthenticated = auth?.isAuthenticated ?? false
return ( const isAdmin = auth?.isAdmin ?? false
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8"> const currentUserId = auth?.userId ?? ""
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Access Denied</CardTitle>
<CardDescription>You need to be logged in to access the dashboard.</CardDescription>
</CardHeader>
</Card>
</main>
)
}
// Loading state const handleApprove = (submissionId: string) => {
if (isLoading) { approveMutation.mutate(submissionId)
return ( }
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</CardContent>
</Card>
</main>
)
}
// Error state const handleReject = (submissionId: string) => {
if (submissionsError) { rejectMutation.mutate(submissionId)
return ( }
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin
? "Review and manage all icon submissions."
: "View your icon submissions and track their status."
}
</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading submissions</AlertTitle>
<AlertDescription>
Failed to load submissions. Please try again.
<Button
variant="outline"
size="sm"
className="ml-4"
onClick={() => refetch()}
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</main>
)
}
// Success state // Not authenticated
return ( if (!authLoading && !isAuthenticated) {
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8"> return (
<Card className="bg-background/50 border shadow-lg"> <main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<CardHeader> <Card className="bg-background/50 border shadow-lg">
<CardTitle>Submissions Dashboard</CardTitle> <CardHeader>
<CardDescription> <CardTitle>Access Denied</CardTitle>
{isAdmin <CardDescription>You need to be logged in to access the dashboard.</CardDescription>
? "Review and manage all icon submissions. Click on a row to see details." </CardHeader>
: "View your icon submissions and track their status." </Card>
} </main>
</CardDescription> )
</CardHeader> }
<CardContent>
<SubmissionsDataTable // Loading state
data={submissions} if (isLoading) {
isAdmin={isAdmin} return (
currentUserId={currentUserId} <main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
onApprove={handleApprove} <Card className="bg-background/50 border shadow-lg">
onReject={handleReject} <CardHeader>
isApproving={approveMutation.isPending} <div className="space-y-2">
isRejecting={rejectMutation.isPending} <Skeleton className="h-8 w-64" />
/> <Skeleton className="h-4 w-96" />
</CardContent> </div>
</Card> </CardHeader>
</main> <CardContent>
) <div className="space-y-4">
<Skeleton className="h-10 w-full" />
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</CardContent>
</Card>
</main>
)
}
// Error state
if (submissionsError) {
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin ? "Review and manage all icon submissions." : "View your icon submissions and track their status."}
</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading submissions</AlertTitle>
<AlertDescription>
Failed to load submissions. Please try again.
<Button variant="outline" size="sm" className="ml-4" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</main>
)
}
// Success state
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin
? "Review and manage all icon submissions. Click on a row to see details."
: "View your icon submissions and track their status."}
</CardDescription>
</CardHeader>
<CardContent>
<SubmissionsDataTable
data={submissions}
isAdmin={isAdmin}
currentUserId={currentUserId}
onApprove={handleApprove}
onReject={handleReject}
isApproving={approveMutation.isPending}
isRejecting={rejectMutation.isPending}
/>
</CardContent>
</Card>
</main>
)
} }

View File

@@ -8,8 +8,8 @@ import { PostHogProvider } from "@/components/PostHogProvider"
import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants" import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants"
import { getTotalIcons } from "@/lib/api" import { getTotalIcons } from "@/lib/api"
import "./globals.css" import "./globals.css"
import { ThemeProvider } from "./theme-provider"
import { Providers } from "@/components/providers" import { Providers } from "@/components/providers"
import { ThemeProvider } from "./theme-provider"
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",

View File

@@ -115,19 +115,19 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) {
categories: selectedCategories, categories: selectedCategories,
sort: sortOption, sort: sortOption,
}) as IconWithStatus[] }) as IconWithStatus[]
return result return result
}, [icons, debouncedQuery, selectedCategories, sortOption]) }, [icons, debouncedQuery, selectedCategories, sortOption])
const groupedIcons = useMemo(() => { const groupedIcons = useMemo(() => {
const statusPriority = { pending: 0, approved: 1, rejected: 2, added_to_collection: 3 } const statusPriority = { pending: 0, approved: 1, rejected: 2, added_to_collection: 3 }
const groups: Record<string, IconWithStatus[]> = {} const groups: Record<string, IconWithStatus[]> = {}
for (const icon of filteredIcons) { for (const icon of filteredIcons) {
const iconWithStatus = icon as IconWithStatus const iconWithStatus = icon as IconWithStatus
const status = iconWithStatus.status || 'pending' const status = iconWithStatus.status || "pending"
if (!groups[status]) { if (!groups[status]) {
groups[status] = [] groups[status] = []
} }
@@ -136,8 +136,7 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) {
return Object.entries(groups) return Object.entries(groups)
.sort(([a], [b]) => { .sort(([a], [b]) => {
return (statusPriority[a as keyof typeof statusPriority] ?? 999) - return (statusPriority[a as keyof typeof statusPriority] ?? 999) - (statusPriority[b as keyof typeof statusPriority] ?? 999)
(statusPriority[b as keyof typeof statusPriority] ?? 999)
}) })
.map(([status, items]) => ({ status, items })) .map(([status, items]) => ({ status, items }))
}, [filteredIcons]) }, [filteredIcons])
@@ -410,7 +409,7 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) {
{getStatusDisplayName(status)} {getStatusDisplayName(status)}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{items.length} {items.length === 1 ? 'icon' : 'icons'} {items.length} {items.length === 1 ? "icon" : "icons"}
</span> </span>
</div> </div>
<Card className="bg-background/50 border shadow-lg"> <Card className="bg-background/50 border shadow-lg">
@@ -425,4 +424,3 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) {
</> </>
) )
} }

View File

@@ -1,6 +1,6 @@
"use client" "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 Link from "next/link"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { IconSubmissionForm } from "@/components/icon-submission-form" import { IconSubmissionForm } from "@/components/icon-submission-form"
@@ -14,12 +14,7 @@ import { CommandMenu } from "./command-menu"
import { HeaderNav } from "./header-nav" import { HeaderNav } from "./header-nav"
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
interface UserData { interface UserData {
@@ -119,13 +114,8 @@ export function Header() {
<header className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50"> <header className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50">
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18"> <div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
<div className="flex items-center gap-2 md:gap-6"> <div className="flex items-center gap-2 md:gap-6">
<Link <Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
href="/" <span className="transition-colors duration-300 group-hover:">Dashboard Icons</span>
className="text-lg md:text-xl font-bold group hidden md:block"
>
<span className="transition-colors duration-300 group-hover:">
Dashboard Icons
</span>
</Link> </Link>
<div className="flex-nowrap"> <div className="flex-nowrap">
<HeaderNav isLoggedIn={isLoggedIn} /> <HeaderNav isLoggedIn={isLoggedIn} />
@@ -134,11 +124,7 @@ export function Header() {
<div className="flex items-center gap-2 md:gap-4"> <div className="flex items-center gap-2 md:gap-4">
{/* Desktop search button */} {/* Desktop search button */}
<div className="hidden md:block"> <div className="hidden md:block">
<Button <Button variant="outline" className="gap-2 cursor-pointer transition-all duration-300" onClick={openCommandMenu}>
variant="outline"
className="gap-2 cursor-pointer transition-all duration-300"
onClick={openCommandMenu}
>
<Search className="h-4 w-4 transition-all duration-300" /> <Search className="h-4 w-4 transition-all duration-300" />
<span>Find icons</span> <span>Find icons</span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100"> <kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100">
@@ -165,11 +151,7 @@ export function Header() {
{isLoggedIn ? ( {isLoggedIn ? (
<IconSubmissionForm <IconSubmissionForm
trigger={ trigger={
<Button <Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2">
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2"
>
<PlusCircle className="h-5 w-5 transition-all duration-300" /> <PlusCircle className="h-5 w-5 transition-all duration-300" />
<span className="sr-only">Submit icon(s)</span> <span className="sr-only">Submit icon(s)</span>
</Button> </Button>
@@ -201,11 +183,7 @@ export function Header() {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button variant="ghost" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 gap-1.5" asChild>
variant="ghost"
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 gap-1.5"
asChild
>
<Link href={REPO_PATH} target="_blank" className="group flex items-center"> <Link href={REPO_PATH} target="_blank" className="group flex items-center">
<Github className="h-5 w-5 group-hover: transition-all duration-300" /> <Github className="h-5 w-5 group-hover: transition-all duration-300" />
{stars > 0 && ( {stars > 0 && (
@@ -224,7 +202,7 @@ export function Header() {
</TooltipProvider> </TooltipProvider>
</div> </div>
<ThemeSwitcher /> <ThemeSwitcher />
{isLoggedIn && userData && ( {isLoggedIn && userData && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -234,13 +212,8 @@ export function Header() {
size="icon" size="icon"
> >
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage <AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
src={userData.avatar || "/placeholder.svg"} <AvatarFallback className="text-xs">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
alt={userData.username}
/>
<AvatarFallback className="text-xs">
{userData.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
<span className="sr-only">User menu</span> <span className="sr-only">User menu</span>
</Button> </Button>
@@ -249,29 +222,18 @@ export function Header() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3 px-1"> <div className="flex items-center gap-3 px-1">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarImage <AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
src={userData.avatar || "/placeholder.svg"} <AvatarFallback className="text-sm font-semibold">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
alt={userData.username}
/>
<AvatarFallback className="text-sm font-semibold">
{userData.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0"> <div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{userData.username}</p> <p className="text-sm font-semibold truncate">{userData.username}</p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">{userData.email}</p>
{userData.email}
</p>
</div> </div>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Button <Button asChild variant="ghost" className="w-full justify-start gap-2 hover:bg-muted">
asChild
variant="ghost"
className="w-full justify-start gap-2 hover:bg-muted"
>
<Link href="/dashboard"> <Link href="/dashboard">
<LayoutDashboard className="h-4 w-4" /> <LayoutDashboard className="h-4 w-4" />
Dashboard Dashboard
@@ -294,13 +256,7 @@ export function Header() {
</div> </div>
{/* Single instance of CommandMenu */} {/* Single instance of CommandMenu */}
{isLoaded && ( {isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
<CommandMenu
icons={iconsData}
open={commandMenuOpen}
onOpenChange={setCommandMenuOpen}
/>
)}
{/* Login Modal */} {/* Login Modal */}
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} /> <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />

View File

@@ -7,14 +7,12 @@ import type { Icon } from "@/types/icons"
export function IconCard({ name, data: iconData, matchedAlias }: { name: string; data: Icon; matchedAlias?: string }) { export function IconCard({ name, data: iconData, matchedAlias }: { name: string; data: Icon; matchedAlias?: string }) {
const formatedIconName = formatIconName(name) const formatedIconName = formatIconName(name)
const isCommunityIcon = iconData.base.startsWith("http") const isCommunityIcon = iconData.base.startsWith("http")
const imageUrl = isCommunityIcon const imageUrl = isCommunityIcon ? iconData.base : `${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}`
? iconData.base
: `${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}`
const linkHref = isCommunityIcon ? `/community/${name}` : `/icons/${name}` const linkHref = isCommunityIcon ? `/community/${name}` : `/icons/${name}`
return ( return (
<MagicCard className="rounded-md shadow-md"> <MagicCard className="rounded-md shadow-md">
<Link prefetch={false} href={linkHref} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer"> <Link prefetch={false} href={linkHref} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">

View File

@@ -4,13 +4,7 @@ import { Github } from "lucide-react"
import type React from "react" import type React from "react"
import { useRef, useState } from "react" import { useRef, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
@@ -30,7 +24,6 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
const [error, setError] = useState("") const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const emailRef = useRef<HTMLInputElement>(null) const emailRef = useRef<HTMLInputElement>(null)
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError("") setError("")
@@ -95,21 +88,21 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-background border shadow-2xl "> <DialogContent className="sm:max-w-md bg-background border shadow-2xl ">
<DialogHeader className="space-y-3"> <DialogHeader className="space-y-3">
<DialogTitle className="text-2xl font-bold"> <DialogTitle className="text-2xl font-bold">{isRegister ? "Create account" : "Sign in"}</DialogTitle>
{isRegister ? "Create account" : "Sign in"}
</DialogTitle>
<DialogDescription className="text-base"> <DialogDescription className="text-base">
{isRegister {isRegister ? "Enter your details to create an account" : "Enter your credentials to continue"}
? "Enter your details to create an account"
: "Enter your credentials to continue"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 pt-2"> <form onSubmit={handleSubmit} className="space-y-5 pt-2">
{error && ( {error && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 px-4 py-3 rounded-lg flex items-start gap-2"> <div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 px-4 py-3 rounded-lg flex items-start gap-2">
<svg className="h-5 w-5 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg> </svg>
<span>{error}</span> <span>{error}</span>
</div> </div>
@@ -135,7 +128,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
<fieldset className="space-y-2 border-0 p-0"> <fieldset className="space-y-2 border-0 p-0">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium"> <Label htmlFor="email" className="text-sm font-medium">
Email or Username Email {!isRegister && "or Username"}
</Label> </Label>
<Input <Input
id="email" id="email"
@@ -145,7 +138,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
name="email" name="email"
type="text" type="text"
autoComplete="username" autoComplete="username"
placeholder="Enter your email or username" placeholder={`Enter your email${isRegister ? "" : " or username"}`}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
aria-invalid={error ? "true" : "false"} aria-invalid={error ? "true" : "false"}
@@ -153,9 +146,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
required required
/> />
{isRegister && ( {isRegister && (
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">Used only to send you updates about your submissions</p>
Used only to send you updates about your submissions
</p>
)} )}
</div> </div>
@@ -177,9 +168,7 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
className="h-11 text-base" className="h-11 text-base"
required required
/> />
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">This will be displayed publicly with your submissions</p>
This will be displayed publicly with your submissions
</p>
</div> </div>
)} )}
@@ -225,16 +214,16 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
</fieldset> </fieldset>
<footer className="space-y-4 pt-2"> <footer className="space-y-4 pt-2">
<Button <Button type="submit" className="w-full h-11 text-base font-semibold shadow-sm" disabled={isLoading}>
type="submit"
className="w-full h-11 text-base font-semibold shadow-sm"
disabled={isLoading}
>
{isLoading ? ( {isLoading ? (
<> <>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
Please wait... Please wait...
</> </>
@@ -263,4 +252,3 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
</Dialog> </Dialog>
) )
} }

View File

@@ -1,25 +1,26 @@
'use client' "use client"
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { useState } from 'react' import { useState } from "react"
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({ const [queryClient] = useState(
defaultOptions: { () =>
queries: { new QueryClient({
staleTime: 60 * 1000, // 1 minute defaultOptions: {
refetchOnWindowFocus: false, queries: {
}, staleTime: 60 * 1000, // 1 minute
}, refetchOnWindowFocus: false,
})) },
},
}),
)
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
{children} {children}
</QueryClientProvider> </QueryClientProvider>
) )
} }

View File

@@ -1,403 +1,404 @@
"use client" "use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Calendar, Check, Download, ExternalLink, FileType, FolderOpen, Palette, Tag, User as UserIcon, X } from "lucide-react"
import { Badge } from "@/components/ui/badge" import Image from "next/image"
import { Separator } from "@/components/ui/separator" import Link from "next/link"
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 { IconCard } from "@/components/icon-card"
import { MagicCard } from "@/components/magicui/magic-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { UserDisplay } from "@/components/user-display"
import { pb, type Submission, type User } from "@/lib/pb"
import { formatIconName } from "@/lib/utils" import { formatIconName } from "@/lib/utils"
import type { Icon } from "@/types/icons" 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 // 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 => { const getDisplayName = (submission: Submission, expandedData?: { created_by: User; approved_by: User }): string => {
// Check if we have expanded user data // Check if we have expanded user data
if (expandedData && expandedData.created_by) { if (expandedData && expandedData.created_by) {
const user = expandedData.created_by const user = expandedData.created_by
// Priority: username > email // Priority: username > email
if (user.username) { if (user.username) {
return user.username return user.username
} }
if (user.email) { if (user.email) {
return user.email return user.email
} }
} }
// Fallback to created_by field (could be user ID or username) // Fallback to created_by field (could be user ID or username)
return submission.created_by return submission.created_by
} }
interface SubmissionDetailsProps { interface SubmissionDetailsProps {
submission: Submission submission: Submission
isAdmin: boolean isAdmin: boolean
onUserClick?: (userId: string, displayName: string) => void onUserClick?: (userId: string, displayName: string) => void
onApprove?: () => void onApprove?: () => void
onReject?: () => void onReject?: () => void
isApproving?: boolean isApproving?: boolean
isRejecting?: boolean isRejecting?: boolean
} }
export function SubmissionDetails({ submission, isAdmin, onUserClick, onApprove, onReject, isApproving, isRejecting }: SubmissionDetailsProps) { export function SubmissionDetails({
const expandedData = submission.expand submission,
const displayName = getDisplayName(submission, expandedData) isAdmin,
onUserClick,
// Sanitize extras to ensure we have safe defaults onApprove,
const sanitizedExtras = { onReject,
base: submission.extras?.base || "svg", isApproving,
aliases: submission.extras?.aliases || [], isRejecting,
categories: submission.extras?.categories || [], }: SubmissionDetailsProps) {
colors: submission.extras?.colors || null, const expandedData = submission.expand
wordmark: submission.extras?.wordmark || null const displayName = getDisplayName(submission, expandedData)
}
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", { // Sanitize extras to ensure we have safe defaults
day: "numeric", const sanitizedExtras = {
month: "long", base: submission.extras?.base || "svg",
year: "numeric", aliases: submission.extras?.aliases || [],
hour: "2-digit", categories: submission.extras?.categories || [],
minute: "2-digit", colors: submission.extras?.colors || null,
}) wordmark: submission.extras?.wordmark || null,
}
// Create a mock Icon object for the IconCard component const formattedCreated = new Date(submission.created).toLocaleDateString("en-GB", {
const mockIconData: Icon = { day: "numeric",
base: sanitizedExtras.base, month: "long",
aliases: sanitizedExtras.aliases, year: "numeric",
categories: sanitizedExtras.categories, hour: "2-digit",
update: { minute: "2-digit",
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) => { const formattedUpdated = new Date(submission.updated).toLocaleDateString("en-GB", {
try { day: "numeric",
const response = await fetch(url) month: "long",
const blob = await response.blob() year: "numeric",
const blobUrl = URL.createObjectURL(blob) hour: "2-digit",
const link = document.createElement("a") minute: "2-digit",
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 ( // Create a mock Icon object for the IconCard component
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> const mockIconData: Icon = {
{/* Left Column - Assets Preview */} base: sanitizedExtras.base,
<div className="lg:col-span-1"> aliases: sanitizedExtras.aliases,
<Card className="h-full bg-background/50 border"> categories: sanitizedExtras.categories,
<CardHeader className="pb-3"> update: {
<CardTitle className="text-lg flex items-center gap-2"> timestamp: submission.updated,
<FileType className="w-5 h-5" /> author: {
Assets Preview id: 1,
</CardTitle> name: displayName,
</CardHeader> },
<CardContent className="pt-0"> },
<div className="space-y-4"> colors: sanitizedExtras.colors
{submission.assets.map((asset, index) => ( ? {
<MagicCard key={index} className="p-0 rounded-md"> dark: sanitizedExtras.colors.dark,
<div className="relative"> light: sanitizedExtras.colors.light,
<div className="aspect-square rounded-lg border flex items-center justify-center p-8 bg-muted/30"> }
<Image : undefined,
src={`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}` || "/placeholder.svg"} wordmark: sanitizedExtras.wordmark
alt={`${submission.name} asset ${index + 1}`} ? {
width={200} dark: sanitizedExtras.wordmark.dark,
height={200} light: sanitizedExtras.wordmark.light,
className="max-w-full max-h-full object-contain" }
/> : undefined,
</div> }
<div className="absolute top-2 right-2 flex gap-1">
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
window.open(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, '_blank')
}}
>
<ExternalLink className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
handleDownload(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, `${submission.name}-${index + 1}.${sanitizedExtras.base}`)
}}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
</MagicCard>
))}
{submission.assets.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
No assets available
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Middle Column - Submission Details */} const handleDownload = async (url: string, filename: string) => {
<div className="lg:col-span-2"> try {
<Card className="h-full bg-background/50 border"> const response = await fetch(url)
<CardHeader className="pb-3"> const blob = await response.blob()
<div className="flex items-center justify-between"> const blobUrl = URL.createObjectURL(blob)
<CardTitle className="text-lg flex items-center gap-2"> const link = document.createElement("a")
<Tag className="w-5 h-5" /> link.href = blobUrl
Submission Details link.download = filename
</CardTitle> document.body.appendChild(link)
{(onApprove || onReject) && ( link.click()
<div className="flex gap-2"> document.body.removeChild(link)
{onApprove && ( setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
<Button } catch (error) {
size="sm" console.error("Download error:", error)
color="green" }
variant="outline" }
onClick={(e) => {
e.stopPropagation()
onApprove()
}}
disabled={isApproving || isRejecting}
>
<Check className="w-4 h-4 mr-2" />
{isApproving ? "Approving..." : "Approve"}
</Button>
)}
{onReject && (
<Button
size="sm"
color="red"
variant="destructive"
onClick={(e) => {
e.stopPropagation()
onReject()
}}
disabled={isApproving || isRejecting}
>
<X className="w-4 h-4 mr-2" />
{isRejecting ? "Rejecting..." : "Reject"}
</Button>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<FileType className="w-4 h-4" />
Icon Name
</h3>
<p className="text-lg font-medium capitalize">{formatIconName(submission.name)}</p>
<p className="text-sm text-muted-foreground mt-1">Filename: {submission.name}</p>
</div>
<div> return (
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<FileType className="w-4 h-4" /> {/* Left Column - Assets Preview */}
Base Format <div className="lg:col-span-1">
</h3> <Card className="h-full bg-background/50 border">
<Badge variant="outline" className="uppercase font-mono"> <CardHeader className="pb-3">
{sanitizedExtras.base} <CardTitle className="text-lg flex items-center gap-2">
</Badge> <FileType className="w-5 h-5" />
</div> Assets Preview
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-4">
{submission.assets.map((asset, index) => (
<MagicCard key={index} className="p-0 rounded-md">
<div className="relative">
<div className="aspect-square rounded-lg border flex items-center justify-center p-8 bg-muted/30">
<Image
src={`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}` || "/placeholder.svg"}
alt={`${submission.name} asset ${index + 1}`}
width={200}
height={200}
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="absolute top-2 right-2 flex gap-1">
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
window.open(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, "_blank")
}}
>
<ExternalLink className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
handleDownload(
`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`,
`${submission.name}-${index + 1}.${sanitizedExtras.base}`,
)
}}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
</MagicCard>
))}
{submission.assets.length === 0 && <div className="text-center py-8 text-muted-foreground text-sm">No assets available</div>}
</div>
</CardContent>
</Card>
</div>
{sanitizedExtras.colors && (Object.keys(sanitizedExtras.colors).length > 0) && ( {/* Middle Column - Submission Details */}
<div> <div className="lg:col-span-2">
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> <Card className="h-full bg-background/50 border">
<Palette className="w-4 h-4" /> <CardHeader className="pb-3">
Color Variants <div className="flex items-center justify-between">
</h3> <CardTitle className="text-lg flex items-center gap-2">
<div className="space-y-2"> <Tag className="w-5 h-5" />
{sanitizedExtras.colors.dark && ( Submission Details
<div className="flex items-center gap-2"> </CardTitle>
<span className="text-sm text-muted-foreground min-w-12">Dark:</span> {(onApprove || onReject) && (
<code className="text-xs bg-muted px-2 py-1 rounded font-mono"> <div className="flex gap-2">
{sanitizedExtras.colors.dark} {onApprove && (
</code> <Button
</div> size="sm"
)} color="green"
{sanitizedExtras.colors.light && ( variant="outline"
<div className="flex items-center gap-2"> onClick={(e) => {
<span className="text-sm text-muted-foreground min-w-12">Light:</span> e.stopPropagation()
<code className="text-xs bg-muted px-2 py-1 rounded font-mono"> onApprove()
{sanitizedExtras.colors.light} }}
</code> disabled={isApproving || isRejecting}
</div> >
)} <Check className="w-4 h-4 mr-2" />
</div> {isApproving ? "Approving..." : "Approve"}
</div> </Button>
)} )}
{onReject && (
<Button
size="sm"
color="red"
variant="destructive"
onClick={(e) => {
e.stopPropagation()
onReject()
}}
disabled={isApproving || isRejecting}
>
<X className="w-4 h-4 mr-2" />
{isRejecting ? "Rejecting..." : "Reject"}
</Button>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<FileType className="w-4 h-4" />
Icon Name
</h3>
<p className="text-lg font-medium capitalize">{formatIconName(submission.name)}</p>
<p className="text-sm text-muted-foreground mt-1">Filename: {submission.name}</p>
</div>
{sanitizedExtras.wordmark && (Object.keys(sanitizedExtras.wordmark).length > 0) && ( <div>
<div> <h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> <FileType className="w-4 h-4" />
<FileType className="w-4 h-4" /> Base Format
Wordmark Variants </h3>
</h3> <Badge variant="outline" className="uppercase font-mono">
<div className="space-y-2"> {sanitizedExtras.base}
{sanitizedExtras.wordmark.dark && ( </Badge>
<div className="flex items-center gap-2"> </div>
<span className="text-sm text-muted-foreground min-w-12">Dark:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{sanitizedExtras.wordmark.dark}
</code>
</div>
)}
{sanitizedExtras.wordmark.light && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{sanitizedExtras.wordmark.light}
</code>
</div>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {sanitizedExtras.colors && Object.keys(sanitizedExtras.colors).length > 0 && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<UserIcon className="w-4 h-4" /> <Palette className="w-4 h-4" />
Submitted By Color Variants
</h3> </h3>
<UserDisplay <div className="space-y-2">
userId={submission.created_by} {sanitizedExtras.colors.dark && (
avatar={expandedData.created_by.avatar} <div className="flex items-center gap-2">
displayName={displayName} <span className="text-sm text-muted-foreground min-w-12">Dark:</span>
onClick={onUserClick} <code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.colors.dark}</code>
size="md" </div>
/> )}
</div> {sanitizedExtras.colors.light && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.colors.light}</code>
</div>
)}
</div>
</div>
)}
{submission.approved_by && ( {sanitizedExtras.wordmark && Object.keys(sanitizedExtras.wordmark).length > 0 && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<UserIcon className="w-4 h-4" /> <FileType className="w-4 h-4" />
{submission.status === 'approved' ? 'Approved By' : 'Reviewed By'} Wordmark Variants
</h3> </h3>
<div className="space-y-2">
{sanitizedExtras.wordmark.dark && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Dark:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.wordmark.dark}</code>
</div>
)}
{sanitizedExtras.wordmark.light && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.wordmark.light}</code>
</div>
)}
</div>
</div>
)}
<UserDisplay <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
userId={expandedData.approved_by.id} <div>
displayName={expandedData.approved_by.username} <h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
avatar={expandedData.approved_by.avatar} <UserIcon className="w-4 h-4" />
size="md" Submitted By
/> </h3>
</div> <UserDisplay
)} userId={submission.created_by}
</div> avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={onUserClick}
size="md"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {submission.approved_by && (
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> <h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" /> <UserIcon className="w-4 h-4" />
Created {submission.status === "approved" ? "Approved By" : "Reviewed By"}
</h3> </h3>
<p className="text-sm">{formattedCreated}</p>
</div>
<div> <UserDisplay
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2"> userId={expandedData.approved_by.id}
<Calendar className="w-4 h-4" /> displayName={expandedData.approved_by.username}
Last Updated avatar={expandedData.approved_by.avatar}
</h3> size="md"
<p className="text-sm">{formattedUpdated}</p> />
</div> </div>
</div> )}
</div>
<Separator /> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
Created
</h3>
<p className="text-sm">{formattedCreated}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div>
<div> <h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2"> <Calendar className="w-4 h-4" />
<FolderOpen className="w-4 h-4" /> Last Updated
Categories </h3>
</h3> <p className="text-sm">{formattedUpdated}</p>
{sanitizedExtras.categories.length > 0 ? ( </div>
<div className="flex flex-wrap gap-2"> </div>
{sanitizedExtras.categories.map((category) => (
<Badge key={category} variant="outline" className="border-primary/20 hover:border-primary">
{category
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No categories assigned</p>
)}
</div>
<div> <Separator />
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<Tag className="w-4 h-4" />
Aliases
</h3>
{sanitizedExtras.aliases.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sanitizedExtras.aliases.map((alias) => (
<Badge key={alias} variant="outline" className="text-xs font-mono">
{alias}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No aliases assigned</p>
)}
</div>
</div>
{isAdmin && ( <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Submission ID</h3> <h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<code className="bg-muted px-2 py-1 rounded block break-all font-mono"> <FolderOpen className="w-4 h-4" />
{submission.id} Categories
</code> </h3>
</div> {sanitizedExtras.categories.length > 0 ? (
)} <div className="flex flex-wrap gap-2">
</div> {sanitizedExtras.categories.map((category) => (
</CardContent> <Badge key={category} variant="outline" className="border-primary/20 hover:border-primary">
</Card> {category
</div> .split("-")
</div> .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
) .join(" ")}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No categories assigned</p>
)}
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<Tag className="w-4 h-4" />
Aliases
</h3>
{sanitizedExtras.aliases.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sanitizedExtras.aliases.map((alias) => (
<Badge key={alias} variant="outline" className="text-xs font-mono">
{alias}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No aliases assigned</p>
)}
</div>
</div>
{isAdmin && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Submission ID</h3>
<code className="bg-muted px-2 py-1 rounded block break-all font-mono">{submission.id}</code>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)
} }

View File

@@ -1,443 +1,448 @@
"use client" "use client"
import * as React from "react"
import { import {
type ColumnDef, type ColumnDef,
flexRender, type ColumnFiltersState,
getCoreRowModel, type ExpandedState,
useReactTable, flexRender,
getSortedRowModel, getCoreRowModel,
type SortingState, getExpandedRowModel,
type ExpandedState, getFilteredRowModel,
getExpandedRowModel, getSortedRowModel,
getFilteredRowModel, type SortingState,
type ColumnFiltersState, useReactTable,
} from "@tanstack/react-table" } 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { ChevronDown, ChevronRight, ImageIcon, Check, X, Search, Filter, SortDesc } from "lucide-react" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
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 { UserDisplay } from "@/components/user-display" import { UserDisplay } from "@/components/user-display"
import { pb, type Submission } from "@/lib/pb"
import { cn } from "@/lib/utils"
// Initialize dayjs relative time plugin // Initialize dayjs relative time plugin
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
// Utility function to get display name with priority: username > email > created_by field // Utility function to get display name with priority: username > email > created_by field
const getDisplayName = (submission: Submission, expandedData?: any): string => { const getDisplayName = (submission: Submission, expandedData?: any): string => {
console.log('🏷️ Getting display name for submission:', submission.id) console.log("🏷️ Getting display name for submission:", submission.id)
console.log('👤 created_by field:', submission.created_by) console.log("👤 created_by field:", submission.created_by)
console.log('🔗 expanded data:', expandedData) console.log("🔗 expanded data:", expandedData)
// Check if we have expanded user data // Check if we have expanded user data
if (expandedData && expandedData.created_by) { if (expandedData && expandedData.created_by) {
const user = expandedData.created_by const user = expandedData.created_by
console.log('📋 User data from expand:', user) console.log("📋 User data from expand:", user)
// Priority: username > email // Priority: username > email
if (user.username) { if (user.username) {
console.log('✅ Using username:', user.username) console.log("✅ Using username:", user.username)
return user.username return user.username
} }
if (user.email) { if (user.email) {
console.log('✅ Using email:', user.email) console.log("✅ Using email:", user.email)
return user.email return user.email
} }
} }
// Fallback to created_by field (could be user ID or username) // Fallback to created_by field (could be user ID or username)
console.log('⚠️ Fallback to created_by field:', submission.created_by) console.log("⚠️ Fallback to created_by field:", submission.created_by)
return submission.created_by return submission.created_by
} }
interface SubmissionsDataTableProps { interface SubmissionsDataTableProps {
data: Submission[] data: Submission[]
isAdmin: boolean isAdmin: boolean
currentUserId: string currentUserId: string
onApprove: (id: string) => void onApprove: (id: string) => void
onReject: (id: string) => void onReject: (id: string) => void
isApproving?: boolean isApproving?: boolean
isRejecting?: boolean isRejecting?: boolean
} }
// Group submissions by status with priority order // Group submissions by status with priority order
const groupAndSortSubmissions = (submissions: Submission[]): Submission[] => { const groupAndSortSubmissions = (submissions: Submission[]): Submission[] => {
const statusPriority = { pending: 0, approved: 1, added_to_collection: 2, rejected: 3 } const statusPriority = { pending: 0, approved: 1, added_to_collection: 2, rejected: 3 }
return [...submissions].sort((a, b) => { return [...submissions].sort((a, b) => {
// First, sort by status priority // First, sort by status priority
const statusDiff = statusPriority[a.status] - statusPriority[b.status] const statusDiff = statusPriority[a.status] - statusPriority[b.status]
if (statusDiff !== 0) return statusDiff if (statusDiff !== 0) return statusDiff
// Within same status, sort by updated time (most recent first) // Within same status, sort by updated time (most recent first)
return new Date(b.updated).getTime() - new Date(a.updated).getTime() return new Date(b.updated).getTime() - new Date(a.updated).getTime()
}) })
} }
const getStatusColor = (status: Submission["status"]) => { const getStatusColor = (status: Submission["status"]) => {
switch (status) { switch (status) {
case "approved": case "approved":
return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20" return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20"
case "rejected": case "rejected":
return "bg-red-500/10 text-red-500 border-red-500/20" return "bg-red-500/10 text-red-500 border-red-500/20"
case "pending": case "pending":
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
case "added_to_collection": case "added_to_collection":
return "bg-green-500/10 text-green-500 border-green-500/20" return "bg-green-500/10 text-green-500 border-green-500/20"
default: default:
return "bg-gray-500/10 text-gray-500 border-gray-500/20" return "bg-gray-500/10 text-gray-500 border-gray-500/20"
} }
} }
const getStatusDisplayName = (status: Submission["status"]) => { const getStatusDisplayName = (status: Submission["status"]) => {
switch (status) { switch (status) {
case "pending": case "pending":
return "Pending" return "Pending"
case "approved": case "approved":
return "Approved" return "Approved"
case "rejected": case "rejected":
return "Rejected" return "Rejected"
case "added_to_collection": case "added_to_collection":
return "Added to Collection" return "Added to Collection"
default: default:
return status return status
} }
} }
export function SubmissionsDataTable({ data, isAdmin, currentUserId, onApprove, onReject, isApproving, isRejecting }: SubmissionsDataTableProps) { export function SubmissionsDataTable({
const [sorting, setSorting] = React.useState<SortingState>([]) data,
const [expanded, setExpanded] = React.useState<ExpandedState>({}) isAdmin,
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) currentUserId,
const [globalFilter, setGlobalFilter] = React.useState("") onApprove,
const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null) onReject,
isApproving,
// Handle row expansion - only one row can be expanded at a time isRejecting,
const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => { }: SubmissionsDataTableProps) {
setExpanded(isExpanded ? {} : { [rowId]: true }) const [sorting, setSorting] = React.useState<SortingState>([])
}, []) const [expanded, setExpanded] = React.useState<ExpandedState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
// Group and sort data by status and updated time const [globalFilter, setGlobalFilter] = React.useState("")
const groupedData = React.useMemo(() => { const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null)
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])
const columns: ColumnDef<Submission>[] = [ // Handle row expansion - only one row can be expanded at a time
{ const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => {
id: "expander", setExpanded(isExpanded ? {} : { [rowId]: true })
header: () => null, }, [])
cell: ({ row }) => {
return (
<button
onClick={(e) => {
e.stopPropagation()
handleRowToggle(row.id, row.getIsExpanded())
}}
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
)
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Name
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
},
{
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const status = row.getValue("status") as Submission["status"]
return (
<Badge variant="outline" className={getStatusColor(status)}>
{getStatusDisplayName(status)}
</Badge>
)
},
},
{
accessorKey: "created_by",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Submitted By
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const submission = row.original
const expandedData = (submission as any).expand
const displayName = getDisplayName(submission, expandedData)
const userId = submission.created_by
return (
<div className="flex items-center gap-1">
<UserDisplay
userId={userId}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={handleUserFilter}
size="md"
/>
{userFilter?.userId === userId && (
<X className="h-3 w-3 text-muted-foreground" />
)}
</div>
)
},
},
{
accessorKey: "updated",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Updated
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("updated") as string
return (
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
{dayjs(date).fromNow()}
</div>
)
},
},
{
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 (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
<img
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
alt={name}
className="w-full h-full object-contain"
/>
</div>
)
}
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)
},
},
]
const table = useReactTable({ // Group and sort data by status and updated time
data: groupedData, const groupedData = React.useMemo(() => {
columns, return groupAndSortSubmissions(data)
getCoreRowModel: getCoreRowModel(), }, [data])
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 ( // Handle user filter - filter by user ID but display username
<div className="space-y-4"> const handleUserFilter = React.useCallback(
{/* Search and Filters */} (userId: string, displayName: string) => {
<div className="flex flex-col sm:flex-row gap-4"> if (userFilter?.userId === userId) {
<div className="relative flex-1"> setUserFilter(null)
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by"))
<Input } else {
placeholder="Search submissions..." setUserFilter({ userId, displayName })
value={globalFilter ?? ""} setColumnFilters((prev) => [...prev.filter((filter) => filter.id !== "created_by"), { id: "created_by", value: userId }])
onChange={(event) => setGlobalFilter(String(event.target.value))} }
className="pl-10" },
/> [userFilter],
</div> )
{userFilter && (
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Badge variant="secondary" className="gap-1">
User: {userFilter.displayName}
<Button
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => {
setUserFilter(null)
setColumnFilters(prev => prev.filter(filter => filter.id !== "created_by"))
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
</div>
)}
</div>
{/* Table */} const columns: ColumnDef<Submission>[] = [
<div className="rounded-md border"> {
<Table> id: "expander",
<TableHeader> header: () => null,
{table.getHeaderGroups().map((headerGroup) => ( cell: ({ row }) => {
<TableRow key={headerGroup.id}> return (
{headerGroup.headers.map((header) => { <button
return ( onClick={(e) => {
<TableHead key={header.id}> e.stopPropagation()
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} handleRowToggle(row.id, row.getIsExpanded())
</TableHead> }}
) className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
})} >
</TableRow> {row.getIsExpanded() ? (
))} <ChevronDown className="h-4 w-4 text-muted-foreground" />
</TableHeader> ) : (
<TableBody> <ChevronRight className="h-4 w-4 text-muted-foreground" />
{table.getRowModel().rows?.length ? ( )}
(() => { </button>
let lastStatus: string | null = null )
return table.getRowModel().rows.map((row, index) => { },
const currentStatus = row.original.status },
const showStatusHeader = currentStatus !== lastStatus {
lastStatus = currentStatus accessorKey: "name",
header: ({ column }) => {
return ( return (
<React.Fragment key={row.id}> <Button
{showStatusHeader && ( variant="ghost"
<TableRow className="bg-muted/40 hover:bg-muted/40"> onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
<TableCell colSpan={columns.length} className="py-2 font-semibold text-sm"> className="h-auto p-0 font-semibold hover:bg-transparent"
<div className="flex items-center gap-2"> >
<Badge variant="outline" className={getStatusColor(currentStatus)}> Name
{getStatusDisplayName(currentStatus)} <SortDesc className="ml-2 h-4 w-4" />
</Badge> </Button>
<span className="text-xs text-muted-foreground"> )
{table.getRowModel().rows.filter(r => r.original.status === currentStatus).length} },
{table.getRowModel().rows.filter(r => r.original.status === currentStatus).length === 1 ? ' submission' : ' submissions'} cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
</span> },
</div> {
</TableCell> accessorKey: "status",
</TableRow> header: ({ column }) => {
)} return (
<TableRow <Button
data-state={row.getIsSelected() && "selected"} variant="ghost"
className={cn( onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
"cursor-pointer hover:bg-muted/50 transition-colors", className="h-auto p-0 font-semibold hover:bg-transparent"
row.getIsExpanded() && "bg-muted/30" >
)} Status
onClick={() => handleRowToggle(row.id, row.getIsExpanded())} <SortDesc className="ml-2 h-4 w-4" />
> </Button>
{row.getVisibleCells().map((cell) => ( )
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell> },
))} cell: ({ row }) => {
</TableRow> const status = row.getValue("status") as Submission["status"]
{row.getIsExpanded() && ( return (
<TableRow> <Badge variant="outline" className={getStatusColor(status)}>
<TableCell colSpan={columns.length} className="p-6 bg-muted/20 border-t"> {getStatusDisplayName(status)}
<SubmissionDetails </Badge>
submission={row.original} )
isAdmin={isAdmin} },
onUserClick={handleUserFilter} },
onApprove={row.original.status === "pending" && isAdmin ? () => onApprove(row.original.id) : undefined} {
onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined} accessorKey: "created_by",
isApproving={isApproving} header: ({ column }) => {
isRejecting={isRejecting} return (
/> <Button
</TableCell> variant="ghost"
</TableRow> onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
)} className="h-auto p-0 font-semibold hover:bg-transparent"
</React.Fragment> >
) Submitted By
}) <SortDesc className="ml-2 h-4 w-4" />
})() </Button>
) : ( )
<TableRow> },
<TableCell colSpan={columns.length} className="h-24 text-center"> cell: ({ row }) => {
{globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"} const submission = row.original
</TableCell> const expandedData = (submission as any).expand
</TableRow> const displayName = getDisplayName(submission, expandedData)
)} const userId = submission.created_by
</TableBody>
</Table> return (
</div> <div className="flex items-center gap-1">
</div> <UserDisplay
) userId={userId}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={handleUserFilter}
size="md"
/>
{userFilter?.userId === userId && <X className="h-3 w-3 text-muted-foreground" />}
</div>
)
},
},
{
accessorKey: "updated",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Updated
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("updated") as string
return (
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
{dayjs(date).fromNow()}
</div>
)
},
},
{
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 (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
<img
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
alt={name}
className="w-full h-full object-contain"
/>
</div>
)
}
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)
},
},
]
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 (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search submissions..."
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(String(event.target.value))}
className="pl-10"
/>
</div>
{userFilter && (
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Badge variant="secondary" className="gap-1">
User: {userFilter.displayName}
<Button
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => {
setUserFilter(null)
setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by"))
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
</div>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{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 (
<React.Fragment key={row.id}>
{showStatusHeader && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={columns.length} className="py-2 font-semibold text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getStatusColor(currentStatus)}>
{getStatusDisplayName(currentStatus)}
</Badge>
<span className="text-xs text-muted-foreground">
{table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length}
{table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length === 1
? " submission"
: " submissions"}
</span>
</div>
</TableCell>
</TableRow>
)}
<TableRow
data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer hover:bg-muted/50 transition-colors", row.getIsExpanded() && "bg-muted/30")}
onClick={() => handleRowToggle(row.id, row.getIsExpanded())}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length} className="p-6 bg-muted/20 border-t">
<SubmissionDetails
submission={row.original}
isAdmin={isAdmin}
onUserClick={handleUserFilter}
onApprove={row.original.status === "pending" && isAdmin ? () => onApprove(row.original.id) : undefined}
onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined}
isApproving={isApproving}
isRejecting={isRejecting}
/>
</TableCell>
</TableRow>
)}
</React.Fragment>
)
})
})()
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
} }

View File

@@ -1,39 +1,30 @@
"use client"; "use client"
import { Github, LogOut, User, LayoutDashboard } from "lucide-react"; import { Github, LayoutDashboard, LogOut, User } from "lucide-react"
import type React from "react"; import Link from "next/link"
import { useState } from "react"; import type React from "react"
import Link from "next/link"; import { useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
DropdownMenu, import { Input } from "@/components/ui/input"
DropdownMenuContent, import { Label } from "@/components/ui/label"
DropdownMenuSeparator, import { Separator } from "@/components/ui/separator"
DropdownMenuTrigger, import { pb } from "@/lib/pb"
} 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 { interface UserData {
username: string; username: string
email: string; email: string
avatar?: string; avatar?: string
} }
interface UserButtonProps { interface UserButtonProps {
asChild?: boolean; asChild?: boolean
isLoggedIn?: boolean; isLoggedIn?: boolean
userData?: UserData; userData?: UserData
} }
export function UserButton({ export function UserButton({ asChild, isLoggedIn = false, userData }: UserButtonProps) {
asChild,
isLoggedIn = false,
userData,
}: UserButtonProps) {
return ( return (
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -43,13 +34,8 @@ export function UserButton({
> >
{isLoggedIn && userData ? ( {isLoggedIn && userData ? (
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage <AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
src={userData.avatar || "/placeholder.svg"} <AvatarFallback className="text-xs">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
alt={userData.username}
/>
<AvatarFallback className="text-xs">
{userData.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
) : ( ) : (
<User className="h-[1.2rem] w-[1.2rem] transition-all group-hover:scale-110" /> <User className="h-[1.2rem] w-[1.2rem] transition-all group-hover:scale-110" />
@@ -57,12 +43,12 @@ export function UserButton({
<span className="sr-only">{isLoggedIn ? "User menu" : "Sign in"}</span> <span className="sr-only">{isLoggedIn ? "User menu" : "Sign in"}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
); )
} }
interface UserMenuProps { interface UserMenuProps {
userData: UserData; userData: UserData
onSignOut: () => void; onSignOut: () => void
} }
export function UserMenu({ userData, onSignOut }: UserMenuProps) { export function UserMenu({ userData, onSignOut }: UserMenuProps) {
@@ -70,29 +56,18 @@ export function UserMenu({ userData, onSignOut }: UserMenuProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3 px-1"> <div className="flex items-center gap-3 px-1">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarImage <AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
src={userData.avatar || "/placeholder.svg"} <AvatarFallback className="text-sm font-semibold">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
alt={userData.username}
/>
<AvatarFallback className="text-sm font-semibold">
{userData.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0"> <div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{userData.username}</p> <p className="text-sm font-semibold truncate">{userData.username}</p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">{userData.email}</p>
{userData.email}
</p>
</div> </div>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Button <Button asChild variant="ghost" className="w-full justify-start gap-2 hover:bg-muted">
asChild
variant="ghost"
className="w-full justify-start gap-2 hover:bg-muted"
>
<Link href="/dashboard"> <Link href="/dashboard">
<LayoutDashboard className="h-4 w-4" /> <LayoutDashboard className="h-4 w-4" />
Dashboard Dashboard
@@ -106,104 +81,98 @@ export function UserMenu({ userData, onSignOut }: UserMenuProps) {
className="w-full justify-start gap-2 text-destructive hover:text-destructive hover:bg-destructive/10" className="w-full justify-start gap-2 text-destructive hover:text-destructive hover:bg-destructive/10"
> >
<div> <div>
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Sign out Sign out
</div> </div>
</Button> </Button>
</div> </div>
); )
} }
interface LoginPopupProps { interface LoginPopupProps {
trigger?: React.ReactNode; trigger?: React.ReactNode
isLoggedIn?: boolean; isLoggedIn?: boolean
userData?: UserData; userData?: UserData
onSignOut?: () => void; onSignOut?: () => void
} }
export function LoginPopup({ export function LoginPopup({ trigger, isLoggedIn = false, userData, onSignOut }: LoginPopupProps) {
trigger, const [open, setOpen] = useState(false)
isLoggedIn = false, const [isRegister, setIsRegister] = useState(false)
userData, const [email, setEmail] = useState("")
onSignOut, const [username, setUsername] = useState("")
}: LoginPopupProps) { const [password, setPassword] = useState("")
const [open, setOpen] = useState(false); const [confirmPassword, setConfirmPassword] = useState("")
const [isRegister, setIsRegister] = useState(false); const [error, setError] = useState("")
const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false)
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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault()
setError(""); setError("")
setIsLoading(true); setIsLoading(true)
try { try {
if (isRegister) { if (isRegister) {
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Passwords do not match"); setError("Passwords do not match")
setIsLoading(false); setIsLoading(false)
return; return
} }
if (!username.trim()) { if (!username.trim()) {
setError("Username is required"); setError("Username is required")
setIsLoading(false); setIsLoading(false)
return; return
} }
if (!email.trim()) { if (!email.trim()) {
setError("Email is required"); setError("Email is required")
setIsLoading(false); setIsLoading(false)
return; return
} }
await pb.collection('users').create({ await pb.collection("users").create({
username: username.trim(), username: username.trim(),
email: email.trim(), email: email.trim(),
password, password,
passwordConfirm: confirmPassword, passwordConfirm: confirmPassword,
}); })
await pb.collection('users').authWithPassword(email, password); await pb.collection("users").authWithPassword(email, password)
} else { } else {
// For login, use email as the identifier // For login, use email as the identifier
await pb.collection('users').authWithPassword(email, password); await pb.collection("users").authWithPassword(email, password)
} }
setOpen(false); setOpen(false)
setEmail(""); setEmail("")
setUsername(""); setUsername("")
setPassword(""); setPassword("")
setConfirmPassword(""); setConfirmPassword("")
} catch (err: any) { } catch (err: any) {
console.error('Auth error:', err); console.error("Auth error:", err)
setError(err?.message || "Authentication failed. Please try again."); setError(err?.message || "Authentication failed. Please try again.")
} finally { } finally {
setIsLoading(false); setIsLoading(false)
} }
}; }
const toggleMode = () => { const toggleMode = () => {
setIsRegister(!isRegister); setIsRegister(!isRegister)
setEmail(""); setEmail("")
setUsername(""); setUsername("")
setPassword(""); setPassword("")
setConfirmPassword(""); setConfirmPassword("")
setError(""); setError("")
}; }
const handleSignOut = () => { const handleSignOut = () => {
setOpen(false); setOpen(false)
// Wait for dropdown close animation before updating parent state // Wait for dropdown close animation before updating parent state
setTimeout(() => { setTimeout(() => {
onSignOut?.(); onSignOut?.()
}, 150); }, 150)
}; }
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}> <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
@@ -214,19 +183,11 @@ export function LoginPopup({
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-semibold text-lg"> <h3 className="font-semibold text-lg">{isRegister ? "Create account" : "Sign in"}</h3>
{isRegister ? "Create account" : "Sign in"}
</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isRegister {isRegister ? "Enter your details to create an account" : "Enter your credentials to continue"}
? "Enter your details to create an account"
: "Enter your credentials to continue"}
</p> </p>
{error && ( {error && <div className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md">{error}</div>}
<div className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md">
{error}
</div>
)}
</div> </div>
<div <div
@@ -260,11 +221,7 @@ export function LoginPopup({
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
/> />
{isRegister && ( {isRegister && <p className="text-xs text-muted-foreground">Used only to send you updates about your submissions</p>}
<p className="text-xs text-muted-foreground">
Used only to send you updates about your submissions
</p>
)}
</div> </div>
{isRegister && ( {isRegister && (
@@ -281,9 +238,7 @@ export function LoginPopup({
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">This will be displayed publicly with your submissions</p>
This will be displayed publicly with your submissions
</p>
</div> </div>
)} )}
@@ -322,7 +277,7 @@ export function LoginPopup({
<footer className="space-y-3"> <footer className="space-y-3">
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Please wait..." : (isRegister ? "Register" : "Login")} {isLoading ? "Please wait..." : isRegister ? "Register" : "Login"}
</Button> </Button>
<div className="text-center text-sm"> <div className="text-center text-sm">
@@ -339,5 +294,5 @@ export function LoginPopup({
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); )
} }

View File

@@ -5,63 +5,53 @@ import { Button } from "@/components/ui/button"
import { pb } from "@/lib/pb" import { pb } from "@/lib/pb"
interface UserDisplayProps { interface UserDisplayProps {
userId?: string userId?: string
displayName: string displayName: string
onClick?: (userId: string, displayName: string) => void onClick?: (userId: string, displayName: string) => void
size?: "sm" | "md" | "lg" size?: "sm" | "md" | "lg"
showAvatar?: boolean showAvatar?: boolean
avatar?: string avatar?: string
} }
const sizeClasses = { const sizeClasses = {
sm: "h-6 w-6", sm: "h-6 w-6",
md: "h-8 w-8", md: "h-8 w-8",
lg: "h-10 w-10" lg: "h-10 w-10",
} }
const textSizeClasses = { const textSizeClasses = {
sm: "text-xs", sm: "text-xs",
md: "text-sm", md: "text-sm",
lg: "text-sm" lg: "text-sm",
} }
export function UserDisplay({ export function UserDisplay({ userId, avatar, displayName, onClick, size = "sm", showAvatar = true }: UserDisplayProps) {
userId, // Avatar URL will attempt to load from PocketBase
avatar, // If it doesn't exist, the AvatarFallback will display instead
displayName, const avatarUrl = userId ? `${pb.baseURL}/api/files/_pb_users_auth_/${userId}/${avatar}?thumb=100x100` : undefined
onClick,
size = "sm",
showAvatar = true
}: UserDisplayProps) {
// Avatar URL will attempt to load from PocketBase
// If it doesn't exist, the AvatarFallback will display instead
const avatarUrl = userId ? `${pb.baseURL}/api/files/_pb_users_auth_/${userId}/${avatar}?thumb=100x100` : undefined
return ( return (
<div className="flex items-center gap-2 "> <div className="flex items-center gap-2 ">
{showAvatar && ( {showAvatar && (
<Avatar className={sizeClasses[size]}> <Avatar className={sizeClasses[size]}>
{avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />} {avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />}
<AvatarFallback className={textSizeClasses[size]}> <AvatarFallback className={textSizeClasses[size]}>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
{displayName.slice(0, 2).toUpperCase()} </Avatar>
</AvatarFallback> )}
</Avatar> {onClick && userId ? (
)} <Button
{onClick && userId ? ( variant="link"
<Button className={`h-auto p-0 ${textSizeClasses[size]} hover:underline`}
variant="link" onClick={(e) => {
className={`h-auto p-0 ${textSizeClasses[size]} hover:underline`} e.stopPropagation()
onClick={(e) => { onClick(userId, displayName)
e.stopPropagation() }}
onClick(userId, displayName) >
}} {displayName}
> </Button>
{displayName} ) : (
</Button> <span className={`${textSizeClasses[size]}`}>{displayName}</span>
) : ( )}
<span className={`${textSizeClasses[size]}`}>{displayName}</span> </div>
)} )
</div>
)
} }

View File

@@ -1,140 +1,147 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { pb, type Submission } from '@/lib/pb' import { toast } from "sonner"
import { toast } from 'sonner' import { pb, type Submission } from "@/lib/pb"
// Query key factory // Query key factory
export const submissionKeys = { export const submissionKeys = {
all: ['submissions'] as const, all: ["submissions"] as const,
lists: () => [...submissionKeys.all, 'list'] as const, lists: () => [...submissionKeys.all, "list"] as const,
list: (filters?: Record<string, any>) => [...submissionKeys.lists(), filters] as const, list: (filters?: Record<string, any>) => [...submissionKeys.lists(), filters] as const,
} }
// Fetch all submissions // Fetch all submissions
export function useSubmissions() { export function useSubmissions() {
return useQuery({ return useQuery({
queryKey: submissionKeys.lists(), queryKey: submissionKeys.lists(),
queryFn: async () => { queryFn: async () => {
console.log('🔍 Fetching submissions...') console.log("🔍 Fetching submissions...")
const records = await pb.collection('submissions').getFullList<Submission>({ const records = await pb.collection("submissions").getFullList<Submission>({
sort: '-updated', sort: "-updated",
expand: 'created_by,approved_by', expand: "created_by,approved_by",
requestKey: null, requestKey: null,
}) })
console.log('📊 Fetched submissions:', records.length) console.log("📊 Fetched submissions:", records.length)
if (records.length > 0) { if (records.length > 0) {
console.log('📋 First submission sample:', records[0]) console.log("📋 First submission sample:", records[0])
console.log('👤 First submission created_by field:', records[0].created_by) console.log("👤 First submission created_by field:", records[0].created_by)
console.log('🔗 First submission expand data:', (records[0] as any).expand) console.log("🔗 First submission expand data:", (records[0] as any).expand)
} }
return records return records
}, },
}) })
} }
// Approve submission mutation // Approve submission mutation
export function useApproveSubmission() { export function useApproveSubmission() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (submissionId: string) => { mutationFn: async (submissionId: string) => {
return await pb.collection('submissions').update(submissionId, { return await pb.collection("submissions").update(
status: 'approved', submissionId,
approved_by: pb.authStore.record?.id || '' {
}, { status: "approved",
requestKey: null approved_by: pb.authStore.record?.id || "",
}) },
}, {
onSuccess: (data) => { requestKey: null,
// Invalidate and refetch submissions },
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }) )
},
toast.success("Submission approved", { onSuccess: (data) => {
description: "The submission has been approved successfully", // Invalidate and refetch submissions
}) queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
},
onError: (error: any) => { toast.success("Submission approved", {
console.error("Error approving submission:", error) description: "The submission has been approved successfully",
if (!error.message?.includes('autocancelled') && !error.name?.includes('AbortError')) { })
toast.error("Failed to approve submission", { },
description: error.message || "An error occurred", onError: (error: any) => {
}) console.error("Error approving submission:", error)
} if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
}, toast.error("Failed to approve submission", {
}) description: error.message || "An error occurred",
})
}
},
})
} }
// Reject submission mutation // Reject submission mutation
export function useRejectSubmission() { export function useRejectSubmission() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (submissionId: string) => { mutationFn: async (submissionId: string) => {
return await pb.collection('submissions').update(submissionId, { return await pb.collection("submissions").update(
status: 'rejected', submissionId,
approved_by: pb.authStore.record?.id || '' {
}, { status: "rejected",
requestKey: null approved_by: pb.authStore.record?.id || "",
}) },
}, {
onSuccess: () => { requestKey: null,
// Invalidate and refetch submissions },
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() }) )
},
toast.success("Submission rejected", { onSuccess: () => {
description: "The submission has been rejected", // Invalidate and refetch submissions
}) queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
},
onError: (error: any) => { toast.success("Submission rejected", {
console.error("Error rejecting submission:", error) description: "The submission has been rejected",
if (!error.message?.includes('autocancelled') && !error.name?.includes('AbortError')) { })
toast.error("Failed to reject submission", { },
description: error.message || "An error occurred", onError: (error: any) => {
}) console.error("Error rejecting submission:", error)
} if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
}, toast.error("Failed to reject submission", {
}) description: error.message || "An error occurred",
})
}
},
})
} }
// Check authentication status // Check authentication status
export function useAuth() { export function useAuth() {
return useQuery({ return useQuery({
queryKey: ['auth'], queryKey: ["auth"],
queryFn: async () => { queryFn: async () => {
const isValid = pb.authStore.isValid const isValid = pb.authStore.isValid
const userId = pb.authStore.record?.id const userId = pb.authStore.record?.id
if (!isValid || !userId) {
return {
isAuthenticated: false,
isAdmin: false,
userId: '',
}
}
try { if (!isValid || !userId) {
// Fetch the full user record to get the admin status return {
const user = await pb.collection('users').getOne(userId, { isAuthenticated: false,
requestKey: null, isAdmin: false,
}) userId: "",
}
return { }
isAuthenticated: true,
isAdmin: user?.admin === true, try {
userId: userId, // Fetch the full user record to get the admin status
} const user = await pb.collection("users").getOne(userId, {
} catch (error) { requestKey: null,
console.error('Error fetching user:', error) })
return {
isAuthenticated: isValid, return {
isAdmin: false, isAuthenticated: true,
userId: userId || '', isAdmin: user?.admin === true,
} userId: userId,
} }
}, } catch (error) {
staleTime: 5 * 60 * 1000, // 5 minutes console.error("Error fetching user:", error)
retry: false, return {
}) isAuthenticated: isValid,
isAdmin: false,
userId: userId || "",
}
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
})
} }

View File

@@ -12,18 +12,16 @@ import type { IconWithName } from "@/types/icons"
* Note: Do not use the client-side pb instance (with auth store) on the server * Note: Do not use the client-side pb instance (with auth store) on the server
*/ */
function createServerPB() { function createServerPB() {
return new PocketBase(process.env.POCKETBASE_URL || "http://127.0.0.1:8090") return new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090")
} }
/** /**
* Transform a CommunityGallery item to IconWithName format for use with IconSearch * Transform a CommunityGallery item to IconWithName format for use with IconSearch
*/ */
function transformGalleryToIcon(item: CommunityGallery): any { function transformGalleryToIcon(item: CommunityGallery): any {
const pbUrl = process.env.POCKETBASE_URL || "http://127.0.0.1:8090" const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"
const fileUrl = item.assets?.[0] const fileUrl = item.assets?.[0] ? `${pbUrl}/api/files/community_gallery/${item.id}/${item.assets[0]}` : ""
? `${pbUrl}/api/files/community_gallery/${item.id}/${item.assets[0]}`
: ""
const transformed = { const transformed = {
name: item.name, name: item.name,
@@ -43,7 +41,7 @@ function transformGalleryToIcon(item: CommunityGallery): any {
wordmark: item.extras?.wordmark, wordmark: item.extras?.wordmark,
}, },
} }
return transformed return transformed
} }
@@ -62,9 +60,7 @@ export async function fetchCommunitySubmissions(): Promise<IconWithName[]> {
sort: "-created", sort: "-created",
}) })
return records return records.filter((item) => item.assets && item.assets.length > 0).map(transformGalleryToIcon)
.filter(item => item.assets && item.assets.length > 0)
.map(transformGalleryToIcon)
} catch (error) { } catch (error) {
console.error("Error fetching community submissions:", error) console.error("Error fetching community submissions:", error)
return [] return []
@@ -80,4 +76,3 @@ export const getCommunitySubmissions = unstable_cache(fetchCommunitySubmissions,
revalidate: 600, revalidate: 600,
tags: ["community-gallery"], tags: ["community-gallery"],
}) })

View File

@@ -1,72 +1,71 @@
import PocketBase, { RecordService } from 'pocketbase'; import PocketBase, { type RecordService } from "pocketbase"
export interface User { export interface User {
id: string id: string
username: string username: string
email: string email: string
admin?: boolean admin?: boolean
avatar?: string avatar?: string
created: string created: string
updated: string updated: string
} }
export interface Submission { export interface Submission {
id: string id: string
name: string name: string
assets: string[] assets: string[]
created_by: string created_by: string
status: 'approved' | 'rejected' | 'pending' | 'added_to_collection' status: "approved" | "rejected" | "pending" | "added_to_collection"
approved_by: string approved_by: string
expand: { expand: {
created_by: User created_by: User
approved_by: User approved_by: User
} }
extras: { extras: {
aliases: string[] aliases: string[]
categories: string[] categories: string[]
base?: string base?: string
colors?: { colors?: {
dark?: string dark?: string
light?: string light?: string
} }
wordmark?: { wordmark?: {
dark?: string dark?: string
light?: string light?: string
} }
} }
created: string created: string
updated: string updated: string
} }
export interface CommunityGallery { export interface CommunityGallery {
id: string id: string
name: string name: string
created_by: string created_by: string
status: 'approved' | 'rejected' | 'pending' | 'added_to_collection' status: "approved" | "rejected" | "pending" | "added_to_collection"
assets: string[] assets: string[]
created: string created: string
updated: string updated: string
extras: { extras: {
aliases: string[] aliases: string[]
categories: string[] categories: string[]
base?: string base?: string
colors?: { colors?: {
dark?: string dark?: string
light?: string light?: string
} }
wordmark?: { wordmark?: {
dark?: string dark?: string
light?: string light?: string
} }
} }
} }
interface TypedPocketBase extends PocketBase { interface TypedPocketBase extends PocketBase {
collection(idOrName: string): RecordService // default fallback for any other collection collection(idOrName: string): RecordService // default fallback for any other collection
collection(idOrName: 'users'): RecordService<User> collection(idOrName: "users"): RecordService<User>
collection(idOrName: 'submissions'): RecordService<Submission> collection(idOrName: "submissions"): RecordService<Submission>
collection(idOrName: 'community_gallery'): RecordService<CommunityGallery> collection(idOrName: "community_gallery"): RecordService<CommunityGallery>
} }
export const pb = new PocketBase('http://127.0.0.1:8090') as TypedPocketBase; export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090") as TypedPocketBase

View File

@@ -19,4 +19,3 @@ export async function revalidateSubmissions() {
revalidatePath("/community") revalidatePath("/community")
revalidatePath("/dashboard") revalidatePath("/dashboard")
} }