mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-27 05:29:03 +08:00
format code + change env
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/react-table": "^8.21.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -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: {}
|
|
||||||
@@ -34,4 +34,3 @@ export async function revalidateAllSubmissions() {
|
|||||||
return { success: false, error: "Failed to revalidate" }
|
return { success: false, error: "Failed to revalidate" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,4 +53,3 @@ export default async function CommunityPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Fetch submissions
|
||||||
const {
|
const { data: submissions = [], isLoading: submissionsLoading, error: submissionsError, refetch } = useSubmissions()
|
||||||
data: submissions = [],
|
|
||||||
isLoading: submissionsLoading,
|
|
||||||
error: submissionsError,
|
|
||||||
refetch
|
|
||||||
} = useSubmissions()
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const approveMutation = useApproveSubmission()
|
const approveMutation = useApproveSubmission()
|
||||||
const rejectMutation = useRejectSubmission()
|
const rejectMutation = useRejectSubmission()
|
||||||
|
|
||||||
const isLoading = authLoading || submissionsLoading
|
const isLoading = authLoading || submissionsLoading
|
||||||
const isAuthenticated = auth?.isAuthenticated ?? false
|
const isAuthenticated = auth?.isAuthenticated ?? false
|
||||||
const isAdmin = auth?.isAdmin ?? false
|
const isAdmin = auth?.isAdmin ?? false
|
||||||
const currentUserId = auth?.userId ?? ''
|
const currentUserId = auth?.userId ?? ""
|
||||||
|
|
||||||
const handleApprove = (submissionId: string) => {
|
const handleApprove = (submissionId: string) => {
|
||||||
approveMutation.mutate(submissionId)
|
approveMutation.mutate(submissionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = (submissionId: string) => {
|
const handleReject = (submissionId: string) => {
|
||||||
rejectMutation.mutate(submissionId)
|
rejectMutation.mutate(submissionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not authenticated
|
// Not authenticated
|
||||||
if (!authLoading && !isAuthenticated) {
|
if (!authLoading && !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
<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">
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Access Denied</CardTitle>
|
<CardTitle>Access Denied</CardTitle>
|
||||||
<CardDescription>You need to be logged in to access the dashboard.</CardDescription>
|
<CardDescription>You need to be logged in to access the dashboard.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
<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">
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-64" />
|
||||||
<Skeleton className="h-4 w-96" />
|
<Skeleton className="h-4 w-96" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-16 w-full" />
|
<Skeleton className="h-16 w-full" />
|
||||||
<Skeleton className="h-16 w-full" />
|
<Skeleton className="h-16 w-full" />
|
||||||
<Skeleton className="h-16 w-full" />
|
<Skeleton className="h-16 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
if (submissionsError) {
|
if (submissionsError) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
<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">
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Submissions Dashboard</CardTitle>
|
<CardTitle>Submissions Dashboard</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{isAdmin
|
{isAdmin ? "Review and manage all icon submissions." : "View your icon submissions and track their status."}
|
||||||
? "Review and manage all icon submissions."
|
</CardDescription>
|
||||||
: "View your icon submissions and track their status."
|
</CardHeader>
|
||||||
}
|
<CardContent>
|
||||||
</CardDescription>
|
<Alert variant="destructive">
|
||||||
</CardHeader>
|
<AlertCircle className="h-4 w-4" />
|
||||||
<CardContent>
|
<AlertTitle>Error loading submissions</AlertTitle>
|
||||||
<Alert variant="destructive">
|
<AlertDescription>
|
||||||
<AlertCircle className="h-4 w-4" />
|
Failed to load submissions. Please try again.
|
||||||
<AlertTitle>Error loading submissions</AlertTitle>
|
<Button variant="outline" size="sm" className="ml-4" onClick={() => refetch()}>
|
||||||
<AlertDescription>
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Failed to load submissions. Please try again.
|
Retry
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
</AlertDescription>
|
||||||
size="sm"
|
</Alert>
|
||||||
className="ml-4"
|
</CardContent>
|
||||||
onClick={() => refetch()}
|
</Card>
|
||||||
>
|
</main>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
)
|
||||||
Retry
|
}
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success state
|
// Success state
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
|
<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">
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Submissions Dashboard</CardTitle>
|
<CardTitle>Submissions Dashboard</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{isAdmin
|
{isAdmin
|
||||||
? "Review and manage all icon submissions. Click on a row to see details."
|
? "Review and manage all icon submissions. Click on a row to see details."
|
||||||
: "View your icon submissions and track their status."
|
: "View your icon submissions and track their status."}
|
||||||
}
|
</CardDescription>
|
||||||
</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<SubmissionsDataTable
|
||||||
<SubmissionsDataTable
|
data={submissions}
|
||||||
data={submissions}
|
isAdmin={isAdmin}
|
||||||
isAdmin={isAdmin}
|
currentUserId={currentUserId}
|
||||||
currentUserId={currentUserId}
|
onApprove={handleApprove}
|
||||||
onApprove={handleApprove}
|
onReject={handleReject}
|
||||||
onReject={handleReject}
|
isApproving={approveMutation.isPending}
|
||||||
isApproving={approveMutation.isPending}
|
isRejecting={rejectMutation.isPending}
|
||||||
isRejecting={rejectMutation.isPending}
|
/>
|
||||||
/>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</main>
|
||||||
</main>
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function CommunityIconSearch({ icons }: CommunityIconSearchProps) {
|
|||||||
|
|
||||||
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) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ export function IconCard({ name, data: iconData, matchedAlias }: { name: 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}`
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +88,9 @@ 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>
|
||||||
|
|
||||||
@@ -109,7 +98,11 @@ export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
|||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
isApproving,
|
||||||
|
isRejecting,
|
||||||
|
}: SubmissionDetailsProps) {
|
||||||
|
const expandedData = submission.expand
|
||||||
|
const displayName = getDisplayName(submission, expandedData)
|
||||||
|
|
||||||
// Sanitize extras to ensure we have safe defaults
|
// Sanitize extras to ensure we have safe defaults
|
||||||
const sanitizedExtras = {
|
const sanitizedExtras = {
|
||||||
base: submission.extras?.base || "svg",
|
base: submission.extras?.base || "svg",
|
||||||
aliases: submission.extras?.aliases || [],
|
aliases: submission.extras?.aliases || [],
|
||||||
categories: submission.extras?.categories || [],
|
categories: submission.extras?.categories || [],
|
||||||
colors: submission.extras?.colors || null,
|
colors: submission.extras?.colors || null,
|
||||||
wordmark: submission.extras?.wordmark || null
|
wordmark: submission.extras?.wordmark || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedCreated = new Date(submission.created).toLocaleDateString("en-GB", {
|
const formattedCreated = new Date(submission.created).toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedUpdated = new Date(submission.updated).toLocaleDateString("en-GB", {
|
const formattedUpdated = new Date(submission.updated).toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create a mock Icon object for the IconCard component
|
// Create a mock Icon object for the IconCard component
|
||||||
const mockIconData: Icon = {
|
const mockIconData: Icon = {
|
||||||
base: sanitizedExtras.base,
|
base: sanitizedExtras.base,
|
||||||
aliases: sanitizedExtras.aliases,
|
aliases: sanitizedExtras.aliases,
|
||||||
categories: sanitizedExtras.categories,
|
categories: sanitizedExtras.categories,
|
||||||
update: {
|
update: {
|
||||||
timestamp: submission.updated,
|
timestamp: submission.updated,
|
||||||
author: {
|
author: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: displayName
|
name: displayName,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
colors: sanitizedExtras.colors ? {
|
colors: sanitizedExtras.colors
|
||||||
dark: sanitizedExtras.colors.dark,
|
? {
|
||||||
light: sanitizedExtras.colors.light
|
dark: sanitizedExtras.colors.dark,
|
||||||
} : undefined,
|
light: sanitizedExtras.colors.light,
|
||||||
wordmark: sanitizedExtras.wordmark ? {
|
}
|
||||||
dark: sanitizedExtras.wordmark.dark,
|
: undefined,
|
||||||
light: sanitizedExtras.wordmark.light
|
wordmark: sanitizedExtras.wordmark
|
||||||
} : undefined
|
? {
|
||||||
}
|
dark: sanitizedExtras.wordmark.dark,
|
||||||
|
light: sanitizedExtras.wordmark.light,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
const handleDownload = async (url: string, filename: string) => {
|
const handleDownload = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
const link = document.createElement("a")
|
const link = document.createElement("a")
|
||||||
link.href = blobUrl
|
link.href = blobUrl
|
||||||
link.download = filename
|
link.download = filename
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download error:", error)
|
console.error("Download error:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Left Column - Assets Preview */}
|
{/* Left Column - Assets Preview */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<Card className="h-full bg-background/50 border">
|
<Card className="h-full bg-background/50 border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<FileType className="w-5 h-5" />
|
<FileType className="w-5 h-5" />
|
||||||
Assets Preview
|
Assets Preview
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{submission.assets.map((asset, index) => (
|
{submission.assets.map((asset, index) => (
|
||||||
<MagicCard key={index} className="p-0 rounded-md">
|
<MagicCard key={index} className="p-0 rounded-md">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="aspect-square rounded-lg border flex items-center justify-center p-8 bg-muted/30">
|
<div className="aspect-square rounded-lg border flex items-center justify-center p-8 bg-muted/30">
|
||||||
<Image
|
<Image
|
||||||
src={`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}` || "/placeholder.svg"}
|
src={`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}` || "/placeholder.svg"}
|
||||||
alt={`${submission.name} asset ${index + 1}`}
|
alt={`${submission.name} asset ${index + 1}`}
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-2 right-2 flex gap-1">
|
<div className="absolute top-2 right-2 flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
window.open(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, '_blank')
|
window.open(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, "_blank")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleDownload(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, `${submission.name}-${index + 1}.${sanitizedExtras.base}`)
|
handleDownload(
|
||||||
}}
|
`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`,
|
||||||
>
|
`${submission.name}-${index + 1}.${sanitizedExtras.base}`,
|
||||||
<Download className="h-3 w-3" />
|
)
|
||||||
</Button>
|
}}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Download className="h-3 w-3" />
|
||||||
</MagicCard>
|
</Button>
|
||||||
))}
|
</div>
|
||||||
{submission.assets.length === 0 && (
|
</div>
|
||||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
</MagicCard>
|
||||||
No assets available
|
))}
|
||||||
</div>
|
{submission.assets.length === 0 && <div className="text-center py-8 text-muted-foreground text-sm">No assets available</div>}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Middle Column - Submission Details */}
|
{/* Middle Column - Submission Details */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="h-full bg-background/50 border">
|
<Card className="h-full bg-background/50 border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Tag className="w-5 h-5" />
|
<Tag className="w-5 h-5" />
|
||||||
Submission Details
|
Submission Details
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{(onApprove || onReject) && (
|
{(onApprove || onReject) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{onApprove && (
|
{onApprove && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="green"
|
color="green"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onApprove()
|
onApprove()
|
||||||
}}
|
}}
|
||||||
disabled={isApproving || isRejecting}
|
disabled={isApproving || isRejecting}
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
{isApproving ? "Approving..." : "Approve"}
|
{isApproving ? "Approving..." : "Approve"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onReject && (
|
{onReject && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="red"
|
color="red"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onReject()
|
onReject()
|
||||||
}}
|
}}
|
||||||
disabled={isApproving || isRejecting}
|
disabled={isApproving || isRejecting}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 mr-2" />
|
<X className="w-4 h-4 mr-2" />
|
||||||
{isRejecting ? "Rejecting..." : "Reject"}
|
{isRejecting ? "Rejecting..." : "Reject"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="space-y-6">
|
<div className="space-y-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-2 flex items-center gap-2">
|
||||||
<FileType className="w-4 h-4" />
|
<FileType className="w-4 h-4" />
|
||||||
Icon Name
|
Icon Name
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-lg font-medium capitalize">{formatIconName(submission.name)}</p>
|
<p className="text-lg font-medium capitalize">{formatIconName(submission.name)}</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Filename: {submission.name}</p>
|
<p className="text-sm text-muted-foreground mt-1">Filename: {submission.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Base Format
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="outline" className="uppercase font-mono">
|
<Badge variant="outline" className="uppercase font-mono">
|
||||||
{sanitizedExtras.base}
|
{sanitizedExtras.base}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sanitizedExtras.colors && (Object.keys(sanitizedExtras.colors).length > 0) && (
|
{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">
|
||||||
<Palette className="w-4 h-4" />
|
<Palette className="w-4 h-4" />
|
||||||
Color Variants
|
Color Variants
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sanitizedExtras.colors.dark && (
|
{sanitizedExtras.colors.dark && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground min-w-12">Dark:</span>
|
<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">
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.colors.dark}</code>
|
||||||
{sanitizedExtras.colors.dark}
|
</div>
|
||||||
</code>
|
)}
|
||||||
</div>
|
{sanitizedExtras.colors.light && (
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
{sanitizedExtras.colors.light && (
|
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
|
||||||
<div className="flex items-center gap-2">
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.colors.light}</code>
|
||||||
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
|
</div>
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
)}
|
||||||
{sanitizedExtras.colors.light}
|
</div>
|
||||||
</code>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sanitizedExtras.wordmark && (Object.keys(sanitizedExtras.wordmark).length > 0) && (
|
{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" />
|
||||||
Wordmark Variants
|
Wordmark Variants
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sanitizedExtras.wordmark.dark && (
|
{sanitizedExtras.wordmark.dark && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground min-w-12">Dark:</span>
|
<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">
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.wordmark.dark}</code>
|
||||||
{sanitizedExtras.wordmark.dark}
|
</div>
|
||||||
</code>
|
)}
|
||||||
</div>
|
{sanitizedExtras.wordmark.light && (
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
{sanitizedExtras.wordmark.light && (
|
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
|
||||||
<div className="flex items-center gap-2">
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.wordmark.light}</code>
|
||||||
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
|
</div>
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
)}
|
||||||
{sanitizedExtras.wordmark.light}
|
</div>
|
||||||
</code>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<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" />
|
<UserIcon className="w-4 h-4" />
|
||||||
Submitted By
|
Submitted By
|
||||||
</h3>
|
</h3>
|
||||||
<UserDisplay
|
<UserDisplay
|
||||||
userId={submission.created_by}
|
userId={submission.created_by}
|
||||||
avatar={expandedData.created_by.avatar}
|
avatar={expandedData.created_by.avatar}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onClick={onUserClick}
|
onClick={onUserClick}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{submission.approved_by && (
|
{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">
|
||||||
<UserIcon className="w-4 h-4" />
|
<UserIcon className="w-4 h-4" />
|
||||||
{submission.status === 'approved' ? 'Approved By' : 'Reviewed By'}
|
{submission.status === "approved" ? "Approved By" : "Reviewed By"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<UserDisplay
|
<UserDisplay
|
||||||
userId={expandedData.approved_by.id}
|
userId={expandedData.approved_by.id}
|
||||||
displayName={expandedData.approved_by.username}
|
displayName={expandedData.approved_by.username}
|
||||||
avatar={expandedData.approved_by.avatar}
|
avatar={expandedData.approved_by.avatar}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<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" />
|
<Calendar className="w-4 h-4" />
|
||||||
Created
|
Created
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm">{formattedCreated}</p>
|
<p className="text-sm">{formattedCreated}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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" />
|
<Calendar className="w-4 h-4" />
|
||||||
Last Updated
|
Last Updated
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm">{formattedUpdated}</p>
|
<p className="text-sm">{formattedUpdated}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
<FolderOpen className="w-4 h-4" />
|
<FolderOpen className="w-4 h-4" />
|
||||||
Categories
|
Categories
|
||||||
</h3>
|
</h3>
|
||||||
{sanitizedExtras.categories.length > 0 ? (
|
{sanitizedExtras.categories.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{sanitizedExtras.categories.map((category) => (
|
{sanitizedExtras.categories.map((category) => (
|
||||||
<Badge key={category} variant="outline" className="border-primary/20 hover:border-primary">
|
<Badge key={category} variant="outline" className="border-primary/20 hover:border-primary">
|
||||||
{category
|
{category
|
||||||
.split("-")
|
.split("-")
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No categories assigned</p>
|
<p className="text-sm text-muted-foreground">No categories assigned</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
|
||||||
<Tag className="w-4 h-4" />
|
<Tag className="w-4 h-4" />
|
||||||
Aliases
|
Aliases
|
||||||
</h3>
|
</h3>
|
||||||
{sanitizedExtras.aliases.length > 0 ? (
|
{sanitizedExtras.aliases.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{sanitizedExtras.aliases.map((alias) => (
|
{sanitizedExtras.aliases.map((alias) => (
|
||||||
<Badge key={alias} variant="outline" className="text-xs font-mono">
|
<Badge key={alias} variant="outline" className="text-xs font-mono">
|
||||||
{alias}
|
{alias}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No aliases assigned</p>
|
<p className="text-sm text-muted-foreground">No aliases assigned</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<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-2">Submission ID</h3>
|
||||||
<code className="bg-muted px-2 py-1 rounded block break-all font-mono">
|
<code className="bg-muted px-2 py-1 rounded block break-all font-mono">{submission.id}</code>
|
||||||
{submission.id}
|
</div>
|
||||||
</code>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
)
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
isRejecting,
|
||||||
|
}: SubmissionsDataTableProps) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
|
const [expanded, setExpanded] = React.useState<ExpandedState>({})
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState("")
|
||||||
|
const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null)
|
||||||
|
|
||||||
// Handle row expansion - only one row can be expanded at a time
|
// Handle row expansion - only one row can be expanded at a time
|
||||||
const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => {
|
const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => {
|
||||||
setExpanded(isExpanded ? {} : { [rowId]: true })
|
setExpanded(isExpanded ? {} : { [rowId]: true })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Group and sort data by status and updated time
|
// Group and sort data by status and updated time
|
||||||
const groupedData = React.useMemo(() => {
|
const groupedData = React.useMemo(() => {
|
||||||
return groupAndSortSubmissions(data)
|
return groupAndSortSubmissions(data)
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
// Handle user filter - filter by user ID but display username
|
// Handle user filter - filter by user ID but display username
|
||||||
const handleUserFilter = React.useCallback((userId: string, displayName: string) => {
|
const handleUserFilter = React.useCallback(
|
||||||
if (userFilter?.userId === userId) {
|
(userId: string, displayName: string) => {
|
||||||
setUserFilter(null)
|
if (userFilter?.userId === userId) {
|
||||||
setColumnFilters(prev => prev.filter(filter => filter.id !== "created_by"))
|
setUserFilter(null)
|
||||||
} else {
|
setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by"))
|
||||||
setUserFilter({ userId, displayName })
|
} else {
|
||||||
setColumnFilters(prev => [
|
setUserFilter({ userId, displayName })
|
||||||
...prev.filter(filter => filter.id !== "created_by"),
|
setColumnFilters((prev) => [...prev.filter((filter) => filter.id !== "created_by"), { id: "created_by", value: userId }])
|
||||||
{ id: "created_by", value: userId }
|
}
|
||||||
])
|
},
|
||||||
}
|
[userFilter],
|
||||||
}, [userFilter])
|
)
|
||||||
|
|
||||||
const columns: ColumnDef<Submission>[] = [
|
const columns: ColumnDef<Submission>[] = [
|
||||||
{
|
{
|
||||||
id: "expander",
|
id: "expander",
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleRowToggle(row.id, row.getIsExpanded())
|
handleRowToggle(row.id, row.getIsExpanded())
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
|
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
|
||||||
>
|
>
|
||||||
{row.getIsExpanded() ? (
|
{row.getIsExpanded() ? (
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
<SortDesc className="ml-2 h-4 w-4" />
|
<SortDesc className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
|
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
>
|
>
|
||||||
Status
|
Status
|
||||||
<SortDesc className="ml-2 h-4 w-4" />
|
<SortDesc className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.getValue("status") as Submission["status"]
|
const status = row.getValue("status") as Submission["status"]
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={getStatusColor(status)}>
|
<Badge variant="outline" className={getStatusColor(status)}>
|
||||||
{getStatusDisplayName(status)}
|
{getStatusDisplayName(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "created_by",
|
accessorKey: "created_by",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
>
|
>
|
||||||
Submitted By
|
Submitted By
|
||||||
<SortDesc className="ml-2 h-4 w-4" />
|
<SortDesc className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const submission = row.original
|
const submission = row.original
|
||||||
const expandedData = (submission as any).expand
|
const expandedData = (submission as any).expand
|
||||||
const displayName = getDisplayName(submission, expandedData)
|
const displayName = getDisplayName(submission, expandedData)
|
||||||
const userId = submission.created_by
|
const userId = submission.created_by
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<UserDisplay
|
<UserDisplay
|
||||||
userId={userId}
|
userId={userId}
|
||||||
avatar={expandedData.created_by.avatar}
|
avatar={expandedData.created_by.avatar}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onClick={handleUserFilter}
|
onClick={handleUserFilter}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
{userFilter?.userId === userId && (
|
{userFilter?.userId === userId && <X className="h-3 w-3 text-muted-foreground" />}
|
||||||
<X className="h-3 w-3 text-muted-foreground" />
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
},
|
||||||
)
|
},
|
||||||
},
|
{
|
||||||
},
|
accessorKey: "updated",
|
||||||
{
|
header: ({ column }) => {
|
||||||
accessorKey: "updated",
|
return (
|
||||||
header: ({ column }) => {
|
<Button
|
||||||
return (
|
variant="ghost"
|
||||||
<Button
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
variant="ghost"
|
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||||
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" />
|
||||||
Updated
|
</Button>
|
||||||
<SortDesc className="ml-2 h-4 w-4" />
|
)
|
||||||
</Button>
|
},
|
||||||
)
|
cell: ({ row }) => {
|
||||||
},
|
const date = row.getValue("updated") as string
|
||||||
cell: ({ row }) => {
|
return (
|
||||||
const date = row.getValue("updated") as string
|
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
|
||||||
return (
|
{dayjs(date).fromNow()}
|
||||||
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
|
</div>
|
||||||
{dayjs(date).fromNow()}
|
)
|
||||||
</div>
|
},
|
||||||
)
|
},
|
||||||
},
|
{
|
||||||
},
|
accessorKey: "assets",
|
||||||
{
|
header: "Preview",
|
||||||
accessorKey: "assets",
|
cell: ({ row }) => {
|
||||||
header: "Preview",
|
const assets = row.getValue("assets") as string[]
|
||||||
cell: ({ row }) => {
|
const name = row.getValue("name") as string
|
||||||
const assets = row.getValue("assets") as string[]
|
if (assets.length > 0) {
|
||||||
const name = row.getValue("name") as string
|
return (
|
||||||
if (assets.length > 0) {
|
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
|
||||||
return (
|
<img
|
||||||
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
|
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
|
||||||
<img
|
alt={name}
|
||||||
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
|
className="w-full h-full object-contain"
|
||||||
alt={name}
|
/>
|
||||||
className="w-full h-full object-contain"
|
</div>
|
||||||
/>
|
)
|
||||||
</div>
|
}
|
||||||
)
|
return (
|
||||||
}
|
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
|
||||||
return (
|
<ImageIcon className="w-6 h-6 text-muted-foreground" />
|
||||||
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
|
</div>
|
||||||
<ImageIcon className="w-6 h-6 text-muted-foreground" />
|
)
|
||||||
</div>
|
},
|
||||||
)
|
},
|
||||||
},
|
]
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: groupedData,
|
data: groupedData,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getExpandedRowModel: getExpandedRowModel(),
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onExpandedChange: setExpanded,
|
onExpandedChange: setExpanded,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
expanded,
|
expanded,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
globalFilter,
|
globalFilter,
|
||||||
},
|
},
|
||||||
getRowCanExpand: () => true,
|
getRowCanExpand: () => true,
|
||||||
globalFilterFn: (row, columnId, value) => {
|
globalFilterFn: (row, columnId, value) => {
|
||||||
const searchValue = value.toLowerCase()
|
const searchValue = value.toLowerCase()
|
||||||
const name = row.getValue("name") as string
|
const name = row.getValue("name") as string
|
||||||
const status = row.getValue("status") as string
|
const status = row.getValue("status") as string
|
||||||
const submission = row.original
|
const submission = row.original
|
||||||
const expandedData = (submission as any).expand
|
const expandedData = (submission as any).expand
|
||||||
const displayName = getDisplayName(submission, expandedData)
|
const displayName = getDisplayName(submission, expandedData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
name.toLowerCase().includes(searchValue) ||
|
name.toLowerCase().includes(searchValue) ||
|
||||||
status.toLowerCase().includes(searchValue) ||
|
status.toLowerCase().includes(searchValue) ||
|
||||||
displayName.toLowerCase().includes(searchValue)
|
displayName.toLowerCase().includes(searchValue)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="relative flex-1">
|
<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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search submissions..."
|
placeholder="Search submissions..."
|
||||||
value={globalFilter ?? ""}
|
value={globalFilter ?? ""}
|
||||||
onChange={(event) => setGlobalFilter(String(event.target.value))}
|
onChange={(event) => setGlobalFilter(String(event.target.value))}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{userFilter && (
|
{userFilter && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
User: {userFilter.displayName}
|
User: {userFilter.displayName}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-auto p-0 hover:bg-transparent"
|
className="h-auto p-0 hover:bg-transparent"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserFilter(null)
|
setUserFilter(null)
|
||||||
setColumnFilters(prev => prev.filter(filter => filter.id !== "created_by"))
|
setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by"))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
(() => {
|
(() => {
|
||||||
let lastStatus: string | null = null
|
let lastStatus: string | null = null
|
||||||
return table.getRowModel().rows.map((row, index) => {
|
return table.getRowModel().rows.map((row, index) => {
|
||||||
const currentStatus = row.original.status
|
const currentStatus = row.original.status
|
||||||
const showStatusHeader = currentStatus !== lastStatus
|
const showStatusHeader = currentStatus !== lastStatus
|
||||||
lastStatus = currentStatus
|
lastStatus = currentStatus
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={row.id}>
|
<React.Fragment key={row.id}>
|
||||||
{showStatusHeader && (
|
{showStatusHeader && (
|
||||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||||
<TableCell colSpan={columns.length} className="py-2 font-semibold text-sm">
|
<TableCell colSpan={columns.length} className="py-2 font-semibold text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className={getStatusColor(currentStatus)}>
|
<Badge variant="outline" className={getStatusColor(currentStatus)}>
|
||||||
{getStatusDisplayName(currentStatus)}
|
{getStatusDisplayName(currentStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<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}
|
||||||
{table.getRowModel().rows.filter(r => r.original.status === currentStatus).length === 1 ? ' submission' : ' submissions'}
|
{table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length === 1
|
||||||
</span>
|
? " submission"
|
||||||
</div>
|
: " submissions"}
|
||||||
</TableCell>
|
</span>
|
||||||
</TableRow>
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
<TableRow
|
</TableRow>
|
||||||
data-state={row.getIsSelected() && "selected"}
|
)}
|
||||||
className={cn(
|
<TableRow
|
||||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
data-state={row.getIsSelected() && "selected"}
|
||||||
row.getIsExpanded() && "bg-muted/30"
|
className={cn("cursor-pointer hover:bg-muted/50 transition-colors", row.getIsExpanded() && "bg-muted/30")}
|
||||||
)}
|
onClick={() => handleRowToggle(row.id, row.getIsExpanded())}
|
||||||
onClick={() => handleRowToggle(row.id, row.getIsExpanded())}
|
>
|
||||||
>
|
{row.getVisibleCells().map((cell) => (
|
||||||
{row.getVisibleCells().map((cell) => (
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
))}
|
||||||
))}
|
</TableRow>
|
||||||
</TableRow>
|
{row.getIsExpanded() && (
|
||||||
{row.getIsExpanded() && (
|
<TableRow>
|
||||||
<TableRow>
|
<TableCell colSpan={columns.length} className="p-6 bg-muted/20 border-t">
|
||||||
<TableCell colSpan={columns.length} className="p-6 bg-muted/20 border-t">
|
<SubmissionDetails
|
||||||
<SubmissionDetails
|
submission={row.original}
|
||||||
submission={row.original}
|
isAdmin={isAdmin}
|
||||||
isAdmin={isAdmin}
|
onUserClick={handleUserFilter}
|
||||||
onUserClick={handleUserFilter}
|
onApprove={row.original.status === "pending" && isAdmin ? () => onApprove(row.original.id) : undefined}
|
||||||
onApprove={row.original.status === "pending" && isAdmin ? () => onApprove(row.original.id) : undefined}
|
onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined}
|
||||||
onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined}
|
isApproving={isApproving}
|
||||||
isApproving={isApproving}
|
isRejecting={isRejecting}
|
||||||
isRejecting={isRejecting}
|
/>
|
||||||
/>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
)}
|
||||||
)}
|
</React.Fragment>
|
||||||
</React.Fragment>
|
)
|
||||||
)
|
})
|
||||||
})
|
})()
|
||||||
})()
|
) : (
|
||||||
) : (
|
<TableRow>
|
||||||
<TableRow>
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
{globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"}
|
||||||
{globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"}
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
</TableRow>
|
)}
|
||||||
)}
|
</TableBody>
|
||||||
</TableBody>
|
</Table>
|
||||||
</Table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() })
|
)
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// Invalidate and refetch submissions
|
||||||
|
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
|
||||||
|
|
||||||
toast.success("Submission approved", {
|
toast.success("Submission approved", {
|
||||||
description: "The submission has been approved successfully",
|
description: "The submission has been approved successfully",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error("Error approving submission:", error)
|
console.error("Error approving submission:", error)
|
||||||
if (!error.message?.includes('autocancelled') && !error.name?.includes('AbortError')) {
|
if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
|
||||||
toast.error("Failed to approve submission", {
|
toast.error("Failed to approve submission", {
|
||||||
description: error.message || "An error occurred",
|
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() })
|
)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate and refetch submissions
|
||||||
|
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
|
||||||
|
|
||||||
toast.success("Submission rejected", {
|
toast.success("Submission rejected", {
|
||||||
description: "The submission has been rejected",
|
description: "The submission has been rejected",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error("Error rejecting submission:", error)
|
console.error("Error rejecting submission:", error)
|
||||||
if (!error.message?.includes('autocancelled') && !error.name?.includes('AbortError')) {
|
if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
|
||||||
toast.error("Failed to reject submission", {
|
toast.error("Failed to reject submission", {
|
||||||
description: error.message || "An error occurred",
|
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) {
|
if (!isValid || !userId) {
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
userId: '',
|
userId: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the full user record to get the admin status
|
// Fetch the full user record to get the admin status
|
||||||
const user = await pb.collection('users').getOne(userId, {
|
const user = await pb.collection("users").getOne(userId, {
|
||||||
requestKey: null,
|
requestKey: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isAdmin: user?.admin === true,
|
isAdmin: user?.admin === true,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user:', error)
|
console.error("Error fetching user:", error)
|
||||||
return {
|
return {
|
||||||
isAuthenticated: isValid,
|
isAuthenticated: isValid,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
userId: userId || '',
|
userId: userId || "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ export async function revalidateSubmissions() {
|
|||||||
revalidatePath("/community")
|
revalidatePath("/community")
|
||||||
revalidatePath("/dashboard")
|
revalidatePath("/dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user