Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
669
src/app/(admin)/admin/projects/[id]/edit/page.tsx
Normal file
669
src/app/(admin)/admin/projects/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,669 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { LogoUpload } from '@/components/shared/logo-upload'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
X,
|
||||
Plus,
|
||||
FileText,
|
||||
Film,
|
||||
Presentation,
|
||||
FileIcon,
|
||||
} from 'lucide-react'
|
||||
import { formatFileSize } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(500),
|
||||
teamName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum([
|
||||
'SUBMITTED',
|
||||
'ELIGIBLE',
|
||||
'ASSIGNED',
|
||||
'SEMIFINALIST',
|
||||
'FINALIST',
|
||||
'REJECTED',
|
||||
]),
|
||||
tags: z.array(z.string()),
|
||||
})
|
||||
|
||||
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
|
||||
|
||||
// File type icons
|
||||
const fileTypeIcons: Record<string, React.ReactNode> = {
|
||||
EXEC_SUMMARY: <FileText className="h-4 w-4" />,
|
||||
PRESENTATION: <Presentation className="h-4 w-4" />,
|
||||
VIDEO: <Film className="h-4 w-4" />,
|
||||
OTHER: <FileIcon className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// Fetch project data
|
||||
const { data: project, isLoading } = trpc.project.get.useQuery({
|
||||
id: projectId,
|
||||
})
|
||||
|
||||
// Fetch files
|
||||
const { data: files, refetch: refetchFiles } = trpc.file.listByProject.useQuery({
|
||||
projectId,
|
||||
})
|
||||
|
||||
// Fetch logo URL
|
||||
const { data: logoUrl, refetch: refetchLogo } = trpc.logo.getUrl.useQuery({
|
||||
projectId,
|
||||
})
|
||||
|
||||
// Fetch existing tags for suggestions
|
||||
const { data: existingTags } = trpc.project.getTags.useQuery({
|
||||
roundId: project?.roundId,
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const updateProject = trpc.project.update.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push(`/admin/projects/${projectId}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteProject = trpc.project.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push('/admin/projects')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteFile = trpc.file.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchFiles()
|
||||
},
|
||||
})
|
||||
|
||||
// Initialize form
|
||||
const form = useForm<UpdateProjectForm>({
|
||||
resolver: zodResolver(updateProjectSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
status: 'SUBMITTED',
|
||||
tags: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Update form when project loads
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
form.reset({
|
||||
title: project.title,
|
||||
teamName: project.teamName || '',
|
||||
description: project.description || '',
|
||||
status: project.status as UpdateProjectForm['status'],
|
||||
tags: project.tags || [],
|
||||
})
|
||||
}
|
||||
}, [project, form])
|
||||
|
||||
const tags = form.watch('tags')
|
||||
|
||||
// Add tag
|
||||
const addTag = useCallback(() => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !tags.includes(tag)) {
|
||||
form.setValue('tags', [...tags, tag])
|
||||
setTagInput('')
|
||||
}
|
||||
}, [tagInput, tags, form])
|
||||
|
||||
// Remove tag
|
||||
const removeTag = useCallback(
|
||||
(tag: string) => {
|
||||
form.setValue(
|
||||
'tags',
|
||||
tags.filter((t) => t !== tag)
|
||||
)
|
||||
},
|
||||
[tags, form]
|
||||
)
|
||||
|
||||
const onSubmit = async (data: UpdateProjectForm) => {
|
||||
await updateProject.mutateAsync({
|
||||
id: projectId,
|
||||
title: data.title,
|
||||
teamName: data.teamName || null,
|
||||
description: data.description || null,
|
||||
status: data.status,
|
||||
tags: data.tags,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteProject.mutateAsync({ id: projectId })
|
||||
}
|
||||
|
||||
const handleDeleteFile = async (fileId: string) => {
|
||||
await deleteFile.mutateAsync({ id: fileId })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <EditProjectSkeleton />
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isPending = updateProject.isPending || deleteProject.isPending
|
||||
|
||||
// Filter tag suggestions (exclude already selected)
|
||||
const tagSuggestions =
|
||||
existingTags?.filter((t) => !tags.includes(t)).slice(0, 5) || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Edit Project</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Update project information and manage files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Basic Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Logo */}
|
||||
<div className="flex items-start gap-4 pb-4 border-b">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={logoUrl}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<FormLabel>Project Logo</FormLabel>
|
||||
<FormDescription>
|
||||
Upload a logo for this project. It will be displayed in project lists and cards.
|
||||
</FormDescription>
|
||||
<div className="pt-2">
|
||||
<LogoUpload
|
||||
project={project}
|
||||
currentLogoUrl={logoUrl}
|
||||
onUploadComplete={() => refetchLogo()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Project title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="teamName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Team Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Team or organization name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Project description..."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="SUBMITTED">Submitted</SelectItem>
|
||||
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
|
||||
<SelectItem value="ASSIGNED">Assigned</SelectItem>
|
||||
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
|
||||
<SelectItem value="FINALIST">Finalist</SelectItem>
|
||||
<SelectItem value="REJECTED">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Tags</CardTitle>
|
||||
<CardDescription>
|
||||
Add tags to categorize this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="Add a tag..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={addTag}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tagSuggestions.length > 0 && tagInput && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Suggestions:
|
||||
</span>
|
||||
{tagSuggestions
|
||||
.filter((t) =>
|
||||
t.toLowerCase().includes(tagInput.toLowerCase())
|
||||
)
|
||||
.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
onClick={() => {
|
||||
if (!tags.includes(tag)) {
|
||||
form.setValue('tags', [...tags, tag])
|
||||
}
|
||||
setTagInput('')
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Files */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Files</CardTitle>
|
||||
<CardDescription>
|
||||
Manage project documents and materials
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{files && files.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>File</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileTypeIcons[file.fileType] || (
|
||||
<FileIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm truncate max-w-[200px]">
|
||||
{file.fileName}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{file.fileType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete file?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{file.fileName}"?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteFile(file.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No files uploaded yet
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm font-medium mb-3">Upload New Files</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
onUploadComplete={() => refetchFiles()}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{updateProject.error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{updateProject.error.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={`/admin/projects/${projectId}`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{updateProject.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that will permanently affect this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" disabled={deleteProject.isPending}>
|
||||
{deleteProject.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Delete Project
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{project.title}" and all
|
||||
associated files, assignments, and evaluations. This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete Project
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{deleteProject.error && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{deleteProject.error.message}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditProjectSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EditProjectPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<EditProjectSkeleton />}>
|
||||
<EditProjectContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user