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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
async function SettingsLoader() {
const settings = await prisma.systemSettings.findMany({
orderBy: [{ category: 'asc' }, { key: 'asc' }],
})
// Convert settings array to key-value map
// For secrets, pass a marker but not the actual value
const settingsMap: Record<string, string> = {}
settings.forEach((setting) => {
if (setting.isSecret && setting.value) {
// Pass marker for UI to show "existing" state
settingsMap[setting.key] = '********'
} else {
settingsMap[setting.key] = setting.value
}
})
return <SettingsContent initialSettings={settingsMap} />
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-full" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default async function SettingsPage() {
const session = await auth()
// Only super admins can access settings
if (session?.user?.role !== 'SUPER_ADMIN') {
redirect('/admin')
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<p className="text-muted-foreground">
Configure platform settings and preferences
</p>
</div>
{/* Content */}
<Suspense fallback={<SettingsSkeleton />}>
<SettingsLoader />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,717 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Plus,
MoreHorizontal,
Pencil,
Trash2,
Loader2,
Tags,
Users,
FolderKanban,
GripVertical,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// Default categories
const DEFAULT_CATEGORIES = [
'Marine Science',
'Technology',
'Policy',
'Conservation',
'Business',
'Education',
'Engineering',
'Other',
]
// Default colors
const TAG_COLORS = [
{ value: '#de0f1e', label: 'Red' },
{ value: '#053d57', label: 'Dark Blue' },
{ value: '#557f8c', label: 'Teal' },
{ value: '#059669', label: 'Green' },
{ value: '#7c3aed', label: 'Purple' },
{ value: '#ea580c', label: 'Orange' },
{ value: '#0284c7', label: 'Blue' },
{ value: '#be185d', label: 'Pink' },
]
interface Tag {
id: string
name: string
description: string | null
category: string | null
color: string | null
isActive: boolean
sortOrder: number
userCount?: number
projectCount?: number
totalUsage?: number
}
function SortableTagRow({
tag,
onEdit,
onDelete,
}: {
tag: Tag
onEdit: (tag: Tag) => void
onDelete: (tag: Tag) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-3 rounded-lg border bg-card p-3 ${
isDragging ? 'opacity-50 shadow-lg' : ''
}`}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div
className="h-4 w-4 rounded-full shrink-0"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{tag.name}</span>
{!tag.isActive && (
<Badge variant="secondary" className="text-xs">
Inactive
</Badge>
)}
</div>
{tag.category && (
<p className="text-xs text-muted-foreground">{tag.category}</p>
)}
</div>
<div className="hidden sm:flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1" title="Users with this tag">
<Users className="h-3.5 w-3.5" />
<span>{tag.userCount || 0}</span>
</div>
<div className="flex items-center gap-1" title="Projects with this tag">
<FolderKanban className="h-3.5 w-3.5" />
<span>{tag.projectCount || 0}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Tag actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(tag)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default function TagsSettingsPage() {
const utils = trpc.useUtils()
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingTag, setEditingTag] = useState<Tag | null>(null)
const [deletingTag, setDeletingTag] = useState<Tag | null>(null)
const [categoryFilter, setCategoryFilter] = useState<string>('all')
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('')
const [color, setColor] = useState('#557f8c')
const [isActive, setIsActive] = useState(true)
// Queries
const { data: tagsData, isLoading } = trpc.tag.list.useQuery({
includeUsageCount: true,
})
const { data: categories } = trpc.tag.getCategories.useQuery()
// Mutations
const createTag = trpc.tag.create.useMutation({
onSuccess: () => {
toast.success('Tag created successfully')
setIsCreateOpen(false)
resetForm()
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const updateTag = trpc.tag.update.useMutation({
onSuccess: () => {
toast.success('Tag updated successfully')
setEditingTag(null)
resetForm()
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const deleteTag = trpc.tag.delete.useMutation({
onSuccess: () => {
toast.success('Tag deleted successfully')
setDeletingTag(null)
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const reorderTags = trpc.tag.reorder.useMutation({
onError: (error) => {
toast.error(error.message)
utils.tag.list.invalidate()
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const resetForm = () => {
setName('')
setDescription('')
setCategory('')
setColor('#557f8c')
setIsActive(true)
}
const openEditDialog = (tag: Tag) => {
setEditingTag(tag)
setName(tag.name)
setDescription(tag.description || '')
setCategory(tag.category || '')
setColor(tag.color || '#557f8c')
setIsActive(tag.isActive)
}
const handleCreate = () => {
if (!name.trim()) {
toast.error('Please enter a tag name')
return
}
createTag.mutate({
name: name.trim(),
description: description || undefined,
category: category || undefined,
color: color || undefined,
})
}
const handleUpdate = () => {
if (!editingTag || !name.trim()) return
updateTag.mutate({
id: editingTag.id,
name: name.trim(),
description: description || null,
category: category || null,
color: color || null,
isActive,
})
}
const handleDelete = () => {
if (!deletingTag) return
deleteTag.mutate({ id: deletingTag.id })
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const tags = filteredTags
const oldIndex = tags.findIndex((t) => t.id === active.id)
const newIndex = tags.findIndex((t) => t.id === over.id)
const newOrder = arrayMove(tags, oldIndex, newIndex)
const items = newOrder.map((tag, index) => ({
id: tag.id,
sortOrder: index,
}))
reorderTags.mutate({ items })
}
}
// Filter tags by category
const filteredTags = (tagsData?.tags || []).filter((tag) => {
if (categoryFilter === 'all') return true
if (categoryFilter === 'uncategorized') return !tag.category
return tag.category === categoryFilter
})
// Get unique categories for filter
const allCategories = Array.from(
new Set([
...DEFAULT_CATEGORIES,
...(categories || []),
])
).sort()
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<Skeleton className="h-8 w-64" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Settings
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Tags className="h-6 w-6" />
Expertise Tags
</h1>
<p className="text-muted-foreground">
Manage tags used for jury expertise and project categorization
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => resetForm()}>
<Plus className="mr-2 h-4 w-4" />
Add Tag
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Tag</DialogTitle>
<DialogDescription>
Add a new expertise tag for categorizing jury members and projects
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Marine Biology"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this expertise area"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="color">
<SelectValue>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{TAG_COLORS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: c.value }}
/>
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={createTag.isPending || !name.trim()}
>
{createTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Tag
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Filter by Category</CardTitle>
</CardHeader>
<CardContent>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="uncategorized">Uncategorized</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Tags List */}
<Card>
<CardHeader>
<CardTitle>Tags ({filteredTags.length})</CardTitle>
<CardDescription>
Drag to reorder tags. Changes are saved automatically.
</CardDescription>
</CardHeader>
<CardContent>
{filteredTags.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Tags className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No tags found</p>
<Button
variant="link"
onClick={() => setIsCreateOpen(true)}
className="mt-2"
>
Create your first tag
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredTags.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{filteredTags.map((tag) => (
<SortableTagRow
key={tag.id}
tag={tag}
onEdit={openEditDialog}
onDelete={setDeletingTag}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog
open={!!editingTag}
onOpenChange={(open) => !open && setEditingTag(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tag</DialogTitle>
<DialogDescription>
Update this expertise tag. Renaming will update all users and projects.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name *</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="edit-category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-color">Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="edit-color">
<SelectValue>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{TAG_COLORS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: c.value }}
/>
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="edit-active">Active</Label>
<p className="text-sm text-muted-foreground">
Inactive tags won&apos;t appear in selection lists
</p>
</div>
<Switch
id="edit-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingTag(null)}>
Cancel
</Button>
<Button
onClick={handleUpdate}
disabled={updateTag.isPending || !name.trim()}
>
{updateTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={!!deletingTag}
onOpenChange={(open) => !open && setDeletingTag(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deletingTag?.name}&quot;? This will
remove the tag from {deletingTag?.userCount || 0} users and{' '}
{deletingTag?.projectCount || 0} projects.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}