292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
DialogTrigger,
|
||
|
|
} from '@/components/ui/dialog'
|
||
|
|
import { Plus, Lock, Unlock, LockKeyhole, Loader2 } from 'lucide-react'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
import { formatDistanceToNow } from 'date-fns'
|
||
|
|
|
||
|
|
type SubmissionWindowManagerProps = {
|
||
|
|
competitionId: string
|
||
|
|
roundId: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
||
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||
|
|
const [name, setName] = useState('')
|
||
|
|
const [slug, setSlug] = useState('')
|
||
|
|
const [roundNumber, setRoundNumber] = useState(1)
|
||
|
|
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
|
||
|
|
// For now, we'll query all windows for the competition
|
||
|
|
// In a real implementation, we'd filter by round or have a dedicated endpoint
|
||
|
|
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||
|
|
id: competitionId,
|
||
|
|
})
|
||
|
|
|
||
|
|
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
||
|
|
toast.success('Submission window created')
|
||
|
|
setIsCreateOpen(false)
|
||
|
|
setName('')
|
||
|
|
setSlug('')
|
||
|
|
setRoundNumber(1)
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
||
|
|
toast.success('Window opened')
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
||
|
|
toast.success('Window closed')
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
|
||
|
|
onSuccess: () => {
|
||
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
||
|
|
toast.success('Window locked')
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
const handleCreate = () => {
|
||
|
|
if (!name || !slug) {
|
||
|
|
toast.error('Name and slug are required')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
createWindowMutation.mutate({
|
||
|
|
competitionId,
|
||
|
|
name,
|
||
|
|
slug,
|
||
|
|
roundNumber,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleNameChange = (value: string) => {
|
||
|
|
setName(value)
|
||
|
|
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||
|
|
setSlug(autoSlug)
|
||
|
|
}
|
||
|
|
|
||
|
|
const windows = competition?.submissionWindows ?? []
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div>
|
||
|
|
<CardTitle className="text-base">Submission Windows</CardTitle>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
File upload windows for this round
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
||
|
|
<Plus className="h-4 w-4 mr-1" />
|
||
|
|
Create Window
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Create Submission Window</DialogTitle>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="windowName">Window Name</Label>
|
||
|
|
<Input
|
||
|
|
id="windowName"
|
||
|
|
placeholder="e.g., Round 1 Submissions"
|
||
|
|
value={name}
|
||
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="windowSlug">Slug</Label>
|
||
|
|
<Input
|
||
|
|
id="windowSlug"
|
||
|
|
placeholder="e.g., round-1-submissions"
|
||
|
|
value={slug}
|
||
|
|
onChange={(e) => setSlug(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="roundNumber">Round Number</Label>
|
||
|
|
<Input
|
||
|
|
id="roundNumber"
|
||
|
|
type="number"
|
||
|
|
min={1}
|
||
|
|
value={roundNumber}
|
||
|
|
onChange={(e) => setRoundNumber(parseInt(e.target.value, 10))}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex gap-2 pt-4">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
className="flex-1"
|
||
|
|
onClick={() => setIsCreateOpen(false)}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
className="flex-1"
|
||
|
|
onClick={handleCreate}
|
||
|
|
disabled={createWindowMutation.isPending}
|
||
|
|
>
|
||
|
|
{createWindowMutation.isPending && (
|
||
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
|
|
)}
|
||
|
|
Create
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||
|
|
Loading windows...
|
||
|
|
</div>
|
||
|
|
) : windows.length === 0 ? (
|
||
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||
|
|
No submission windows yet. Create one to enable file uploads.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{windows.map((window) => {
|
||
|
|
const isPending = !window.windowOpenAt
|
||
|
|
const isOpen = window.windowOpenAt && !window.windowCloseAt
|
||
|
|
const isClosed = window.windowCloseAt && !window.isLocked
|
||
|
|
const isLocked = window.isLocked
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={window.id}
|
||
|
|
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border rounded-lg p-3"
|
||
|
|
>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
||
|
|
<p className="text-sm font-medium truncate">{window.name}</p>
|
||
|
|
{isPending && (
|
||
|
|
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
||
|
|
Pending
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
{isOpen && (
|
||
|
|
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
||
|
|
Open
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
{isClosed && (
|
||
|
|
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
||
|
|
Closed
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
{isLocked && (
|
||
|
|
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
||
|
|
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
||
|
|
Locked
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
||
|
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||
|
|
<span>Round {window.roundNumber}</span>
|
||
|
|
<span>•</span>
|
||
|
|
<span>{window._count.fileRequirements} requirements</span>
|
||
|
|
<span>•</span>
|
||
|
|
<span>{window._count.projectFiles} files</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2 shrink-0">
|
||
|
|
{isPending && (
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
||
|
|
disabled={openWindowMutation.isPending}
|
||
|
|
>
|
||
|
|
{openWindowMutation.isPending ? (
|
||
|
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Unlock className="h-3 w-3 mr-1" />
|
||
|
|
)}
|
||
|
|
Open
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{isOpen && (
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
||
|
|
disabled={closeWindowMutation.isPending}
|
||
|
|
>
|
||
|
|
{closeWindowMutation.isPending ? (
|
||
|
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Lock className="h-3 w-3 mr-1" />
|
||
|
|
)}
|
||
|
|
Close
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{isClosed && (
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
||
|
|
disabled={lockWindowMutation.isPending}
|
||
|
|
>
|
||
|
|
{lockWindowMutation.isPending ? (
|
||
|
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<LockKeyhole className="h-3 w-3 mr-1" />
|
||
|
|
)}
|
||
|
|
Lock
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|