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,196 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
MoreHorizontal,
Mail,
UserCog,
Trash2,
Loader2,
} from 'lucide-react'
interface UserActionsProps {
userId: string
userEmail: string
userStatus: string
}
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
const router = useRouter()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation()
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
const handleDelete = async () => {
try {
await deleteUser.mutateAsync({ id: userId })
toast.success('User deleted successfully')
setShowDeleteDialog(false)
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete user')
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
>
<Mail className="mr-2 h-4 w-4" />
{isSending ? 'Sending...' : 'Send Invite'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {userEmail}? This action cannot be
undone and will remove all their assignments and evaluations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
interface UserMobileActionsProps {
userId: string
userEmail: string
userStatus: string
}
export function UserMobileActions({
userId,
userEmail,
userStatus,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const sendInvitation = trpc.user.sendInvitation.useMutation()
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
return (
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/users/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Invite
</Button>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface CriteriaScoreData {
id: string
name: string
averageScore: number
count: number
}
interface CriteriaScoresProps {
data: CriteriaScoreData[]
}
// Color scale from red to green based on score
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
const formattedData = data.map((d) => ({
...d,
displayName:
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
}))
const overallAverage =
data.length > 0
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Score by Evaluation Criteria</span>
<span className="text-sm font-normal text-muted-foreground">
Overall Avg: {overallAverage.toFixed(2)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="displayName"
tick={{ fontSize: 11 }}
angle={-45}
textAnchor="end"
interval={0}
height={60}
/>
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [
(value ?? 0).toFixed(2),
'Average Score',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as CriteriaScoreData
return `${item.name} (${item.count} ratings)`
}
return ''
}}
/>
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
{formattedData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getScoreColor(entry.averageScore)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Area,
ComposedChart,
Bar,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface TimelineDataPoint {
date: string
daily: number
cumulative: number
}
interface EvaluationTimelineProps {
data: TimelineDataPoint[]
}
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
// Format date for display
const formattedData = data.map((d) => ({
...d,
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
}))
const totalEvaluations =
data.length > 0 ? data[data.length - 1].cumulative : 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Evaluation Progress Over Time</span>
<span className="text-sm font-normal text-muted-foreground">
Total: {totalEvaluations} evaluations
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={formattedData}
margin={{ top: 20, right: 30, bottom: 20, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="dateFormatted"
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" />
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
value ?? 0,
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative',
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend />
<Bar
yAxisId="left"
dataKey="daily"
name="Daily Evaluations"
fill="#8884d8"
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="cumulative"
name="Cumulative Total"
stroke="#82ca9d"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 6 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,135 @@
'use client'
import dynamic from 'next/dynamic'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { getCountryName } from '@/lib/countries'
import { Globe, MapPin } from 'lucide-react'
type CountryData = { countryCode: string; count: number }
type GeographicDistributionProps = {
data: CountryData[]
compact?: boolean
}
const LeafletMap = dynamic(() => import('./leaflet-map'), {
ssr: false,
loading: () => (
<div
className="flex items-center justify-center bg-muted/30 rounded-md animate-pulse"
style={{ height: 400 }}
>
<Globe className="h-8 w-8 text-muted-foreground/40" />
</div>
),
})
const LeafletMapFull = dynamic(() => import('./leaflet-map'), {
ssr: false,
loading: () => (
<div
className="flex items-center justify-center bg-muted/30 rounded-md animate-pulse"
style={{ height: 500 }}
>
<Globe className="h-8 w-8 text-muted-foreground/40" />
</div>
),
})
export function GeographicDistribution({
data,
compact = false,
}: GeographicDistributionProps) {
const validData = data.filter((d) => d.countryCode !== 'UNKNOWN')
const unknownCount = data
.filter((d) => d.countryCode === 'UNKNOWN')
.reduce((sum, d) => sum + d.count, 0)
const totalProjects = data.reduce((sum, d) => sum + d.count, 0)
const countryCount = validData.length
if (data.length === 0 || totalProjects === 0) {
return compact ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No geographic data available
</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Project Origins
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<Globe className="h-12 w-12 text-muted-foreground/30" />
<p className="mt-3 text-muted-foreground">
No geographic data available yet
</p>
</div>
</CardContent>
</Card>
)
}
if (compact) {
return (
<div className="space-y-3">
<LeafletMap data={validData} compact />
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>{countryCount} countries</span>
{unknownCount > 0 && (
<span>{unknownCount} projects without country</span>
)}
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Project Origins
</span>
<span className="text-sm font-normal text-muted-foreground">
{countryCount} countries &middot; {totalProjects} projects
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LeafletMapFull data={validData} />
{unknownCount > 0 && (
<p className="text-xs text-muted-foreground">
{unknownCount} project{unknownCount !== 1 ? 's' : ''} without country data
</p>
)}
{/* Top countries table */}
<div className="space-y-1">
<p className="text-sm font-medium">Top Countries</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
{validData
.sort((a, b) => b.count - a.count)
.slice(0, 10)
.map((d) => (
<div
key={d.countryCode}
className="flex items-center justify-between py-1 border-b border-border/50"
>
<span className="text-muted-foreground">
{getCountryName(d.countryCode)}
</span>
<span className="font-medium tabular-nums">{d.count}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { GeographicDistribution } from './geographic-distribution'
import { Globe } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
type GeographicSummaryCardProps = {
programId: string
}
export function GeographicSummaryCard({ programId }: GeographicSummaryCardProps) {
const { data, isLoading } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId },
{ enabled: !!programId }
)
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Project Origins
</CardTitle>
<CardDescription>Geographic distribution of projects</CardDescription>
</CardHeader>
<CardContent>
<Skeleton className="h-[250px] w-full rounded-md" />
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Project Origins
</CardTitle>
<CardDescription>Geographic distribution of projects</CardDescription>
</CardHeader>
<CardContent>
<GeographicDistribution data={data || []} compact />
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,8 @@
export { ScoreDistributionChart } from './score-distribution'
export { EvaluationTimelineChart } from './evaluation-timeline'
export { StatusBreakdownChart } from './status-breakdown'
export { JurorWorkloadChart } from './juror-workload'
export { ProjectRankingsChart } from './project-rankings'
export { CriteriaScoresChart } from './criteria-scores'
export { GeographicDistribution } from './geographic-distribution'
export { GeographicSummaryCard } from './geographic-summary-card'

View File

@@ -0,0 +1,102 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface JurorWorkloadData {
id: string
name: string
assigned: number
completed: number
completionRate: number
}
interface JurorWorkloadProps {
data: JurorWorkloadData[]
}
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
// Truncate names for display
const formattedData = data.map((d) => ({
...d,
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
}))
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
const overallRate =
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Juror Workload</span>
<span className="text-sm font-normal text-muted-foreground">
{overallRate}% overall completion
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedData}
layout="vertical"
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" />
<YAxis
dataKey="displayName"
type="category"
width={90}
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
value ?? 0,
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as JurorWorkloadData
return `${item.name} (${item.completionRate}% complete)`
}
return ''
}}
/>
<Legend />
<Bar
dataKey="assigned"
name="Assigned"
fill="#8884d8"
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="completed"
name="Completed"
fill="#82ca9d"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useMemo } from 'react'
import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import { COUNTRIES } from '@/lib/countries'
type CountryData = { countryCode: string; count: number }
type LeafletMapProps = {
data: CountryData[]
compact?: boolean
}
export default function LeafletMap({ data, compact }: LeafletMapProps) {
const markers = useMemo(() => {
const maxCount = Math.max(...data.map((d) => d.count), 1)
return data
.filter((d) => d.countryCode !== 'UNKNOWN' && COUNTRIES[d.countryCode])
.map((d) => {
const country = COUNTRIES[d.countryCode]
const ratio = d.count / maxCount
const radius = 5 + ratio * 15
return {
code: d.countryCode,
name: country.name,
position: [country.lat, country.lng] as [number, number],
count: d.count,
radius,
ratio,
}
})
}, [data])
return (
<MapContainer
center={[20, 0]}
zoom={compact ? 1 : 2}
scrollWheelZoom
zoomControl
dragging
doubleClickZoom
style={{
height: compact ? 400 : 500,
width: '100%',
borderRadius: '0.5rem',
background: '#f0f0f0',
}}
attributionControl={false}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
{markers.map((marker) => (
<CircleMarker
key={marker.code}
center={marker.position}
radius={marker.radius}
pathOptions={{
color: '#de0f1e',
fillColor: '#de0f1e',
fillOpacity: 0.35 + marker.ratio * 0.45,
weight: 1.5,
}}
>
<Tooltip direction="top" offset={[0, -marker.radius]}>
<div className="text-xs font-medium">
<span className="font-semibold">{marker.name}</span>
<br />
{marker.count} project{marker.count !== 1 ? 's' : ''}
</div>
</Tooltip>
</CircleMarker>
))}
</MapContainer>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ProjectRankingData {
id: string
title: string
teamName: string | null
status: string
averageScore: number | null
evaluationCount: number
}
interface ProjectRankingsProps {
data: ProjectRankingData[]
limit?: number
}
// Generate color based on score (red to green gradient)
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function ProjectRankingsChart({
data,
limit = 20,
}: ProjectRankingsProps) {
const displayData = data.slice(0, limit).map((d, index) => ({
...d,
rank: index + 1,
displayTitle:
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
score: d.averageScore || 0,
}))
const averageScore =
data.length > 0
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
: 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Project Rankings</span>
<span className="text-sm font-normal text-muted-foreground">
Top {displayData.length} of {data.length} projects
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[500px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={displayData}
layout="vertical"
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" domain={[0, 10]} />
<YAxis
dataKey="displayTitle"
type="category"
width={140}
tick={{ fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as ProjectRankingData & {
rank: number
}
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
}
return ''
}}
/>
<ReferenceLine
x={averageScore}
stroke="#666"
strokeDasharray="5 5"
label={{
value: `Avg: ${averageScore.toFixed(1)}`,
position: 'top',
fill: '#666',
fontSize: 11,
}}
/>
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
{displayData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ScoreDistributionProps {
data: { score: number; count: number }[]
averageScore: number
totalScores: number
}
const COLORS = [
'#de0f1e', // 1 - red (poor)
'#e6382f',
'#ed6141',
'#f38a52',
'#f8b364', // 5 - yellow (average)
'#c9c052',
'#99cc41',
'#6ad82f',
'#3be31e',
'#0bd90f', // 10 - green (excellent)
]
export function ScoreDistributionChart({
data,
averageScore,
totalScores,
}: ScoreDistributionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Score Distribution</span>
<span className="text-sm font-normal text-muted-foreground">
Avg: {averageScore.toFixed(2)} ({totalScores} scores)
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="score"
label={{
value: 'Score',
position: 'insideBottom',
offset: -10,
}}
/>
<YAxis
label={{
value: 'Count',
angle: -90,
position: 'insideLeft',
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
labelFormatter={(label) => `Score: ${label}`}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StatusDataPoint {
status: string
count: number
}
interface StatusBreakdownProps {
data: StatusDataPoint[]
}
const STATUS_COLORS: Record<string, string> = {
PENDING: '#8884d8',
UNDER_REVIEW: '#82ca9d',
SHORTLISTED: '#ffc658',
SEMIFINALIST: '#ff7300',
FINALIST: '#00C49F',
WINNER: '#0088FE',
ELIMINATED: '#de0f1e',
WITHDRAWN: '#999999',
}
const renderCustomLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}: {
cx?: number
cy?: number
midAngle?: number
innerRadius?: number
outerRadius?: number
percent?: number
}) => {
if (cx === undefined || cy === undefined || midAngle === undefined ||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
return null
}
if (percent < 0.05) return null // Don't show labels for small slices
const RADIAN = Math.PI / 180
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
const x = cx + radius * Math.cos(-midAngle * RADIAN)
const y = cy + radius * Math.sin(-midAngle * RADIAN)
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight={600}
>
{`${(percent * 100).toFixed(0)}%`}
</text>
)
}
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
const total = data.reduce((sum, item) => sum + item.count, 0)
// Format status for display
const formattedData = data.map((d) => ({
...d,
name: d.status.replace(/_/g, ' '),
color: STATUS_COLORS[d.status] || '#8884d8',
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Project Status Distribution</span>
<span className="text-sm font-normal text-muted-foreground">
{total} projects
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={formattedData}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomLabel}
outerRadius={100}
innerRadius={50}
fill="#8884d8"
dataKey="count"
nameKey="name"
>
{formattedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
name ?? '',
]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,6 @@
export { StepWelcome } from './step-welcome'
export { StepContact } from './step-contact'
export { StepProject } from './step-project'
export { StepTeam } from './step-team'
export { StepAdditional } from './step-additional'
export { StepReview } from './step-review'

View File

@@ -0,0 +1,138 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { Calendar, GraduationCap, Heart } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { ApplicationFormData } from '@/server/routers/application'
interface StepAdditionalProps {
form: UseFormReturn<ApplicationFormData>
isBusinessConcept: boolean
isStartup: boolean
}
export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAdditionalProps) {
const { register, formState: { errors }, setValue, watch } = form
const wantsMentorship = watch('wantsMentorship')
return (
<WizardStepContent
title="A few more details"
description="Help us understand your background and needs."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-8"
>
{/* Institution (for Business Concepts) */}
{isBusinessConcept && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="institution">
University/School <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="institution"
placeholder="MIT, Stanford, INSEAD..."
{...register('institution')}
className="h-12 text-base"
/>
{errors.institution && (
<p className="text-sm text-destructive">{errors.institution.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the name of your university or educational institution.
</p>
</motion.div>
)}
{/* Startup Created Date (for Startups) */}
{isStartup && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<Label htmlFor="startupCreatedDate">
When was your startup created? <span className="text-destructive">*</span>
</Label>
</div>
<Input
id="startupCreatedDate"
type="date"
{...register('startupCreatedDate')}
className="h-12 text-base"
/>
{errors.startupCreatedDate && (
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
)}
<p className="text-xs text-muted-foreground">
Enter the date your startup was officially registered or founded.
</p>
</motion.div>
)}
{/* Mentorship */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-lg border bg-card p-6"
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Heart className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<Label htmlFor="wantsMentorship" className="text-base font-medium">
Would you like mentorship support?
</Label>
<Switch
id="wantsMentorship"
checked={wantsMentorship}
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Our mentors are industry experts who can help guide your project.
This is optional but highly recommended.
</p>
</div>
</div>
</motion.div>
{/* Referral Source */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-2"
>
<Label htmlFor="referralSource">
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="referralSource"
placeholder="Friend, Social media, Event..."
{...register('referralSource')}
className="h-12 text-base"
/>
</motion.div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input'
import { CountrySelect } from '@/components/ui/country-select'
import type { ApplicationFormData } from '@/server/routers/application'
interface StepContactProps {
form: UseFormReturn<ApplicationFormData>
}
export function StepContact({ form }: StepContactProps) {
const { register, formState: { errors }, setValue, watch } = form
const country = watch('country')
const phone = watch('contactPhone')
return (
<WizardStepContent
title="Tell us about yourself"
description="We'll use this information to contact you about your application."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-md space-y-6"
>
{/* Full Name */}
<div className="space-y-2">
<Label htmlFor="contactName">
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id="contactName"
placeholder="John Smith"
{...register('contactName')}
className="h-12 text-base"
/>
{errors.contactName && (
<p className="text-sm text-destructive">{errors.contactName.message}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="contactEmail">
Email Address <span className="text-destructive">*</span>
</Label>
<Input
id="contactEmail"
type="email"
placeholder="john@example.com"
{...register('contactEmail')}
className="h-12 text-base"
/>
{errors.contactEmail && (
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="contactPhone">
Phone Number <span className="text-destructive">*</span>
</Label>
<PhoneInput
value={phone}
onChange={(value) => setValue('contactPhone', value || '')}
defaultCountry="MC"
className="h-12"
/>
{errors.contactPhone && (
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
)}
</div>
{/* Country */}
<div className="space-y-2">
<Label>
Country <span className="text-destructive">*</span>
</Label>
<CountrySelect
value={country}
onChange={(value) => setValue('country', value)}
placeholder="Select your country"
className="h-12"
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
{/* City (optional) */}
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="city"
placeholder="Monaco"
{...register('city')}
className="h-12 text-base"
/>
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { OceanIssue } from '@prisma/client'
interface OceanIssueOption {
value: OceanIssue
label: string
}
const oceanIssueOptions: OceanIssueOption[] = [
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
{ value: 'OTHER', label: 'Other' },
]
interface StepProjectProps {
form: UseFormReturn<ApplicationFormData>
}
export function StepProject({ form }: StepProjectProps) {
const { register, formState: { errors }, setValue, watch } = form
const oceanIssue = watch('oceanIssue')
const description = watch('description') || ''
return (
<WizardStepContent
title="Tell us about your project"
description="Share the details of your ocean protection initiative."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="projectName">
Name of your project/startup <span className="text-destructive">*</span>
</Label>
<Input
id="projectName"
placeholder="Ocean Guardian AI"
{...register('projectName')}
className="h-12 text-base"
/>
{errors.projectName && (
<p className="text-sm text-destructive">{errors.projectName.message}</p>
)}
</div>
{/* Team Name (optional) */}
<div className="space-y-2">
<Label htmlFor="teamName">
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id="teamName"
placeholder="Blue Innovation Team"
{...register('teamName')}
className="h-12 text-base"
/>
</div>
{/* Ocean Issue */}
<div className="space-y-2">
<Label>
What type of ocean issue does your project address? <span className="text-destructive">*</span>
</Label>
<Select
value={oceanIssue}
onValueChange={(value) => setValue('oceanIssue', value as OceanIssue)}
>
<SelectTrigger className="h-12 text-base">
<SelectValue placeholder="Select an ocean issue" />
</SelectTrigger>
<SelectContent>
{oceanIssueOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.oceanIssue && (
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
</Label>
<p className="text-xs text-muted-foreground">
Keep it brief - you&apos;ll have the opportunity to provide more details later.
</p>
<Textarea
id="description"
placeholder="Our project aims to..."
rows={5}
maxLength={2000}
{...register('description')}
className="text-base resize-none"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{errors.description ? (
<span className="text-destructive">{errors.description.message}</span>
) : (
'Minimum 20 characters'
)}
</span>
<span>{description.length} characters</span>
</div>
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { motion } from 'motion/react'
import { UseFormReturn } from 'react-hook-form'
import {
User,
Mail,
Phone,
MapPin,
Briefcase,
Waves,
Users,
GraduationCap,
Calendar,
Heart,
MessageSquare,
CheckCircle2,
} from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import type { ApplicationFormData } from '@/server/routers/application'
import { countries } from '@/components/ui/country-select'
const oceanIssueLabels: Record<string, string> = {
POLLUTION_REDUCTION: 'Reduction of pollution',
CLIMATE_MITIGATION: 'Climate change mitigation',
TECHNOLOGY_INNOVATION: 'Technology & innovations',
SUSTAINABLE_SHIPPING: 'Sustainable shipping & yachting',
BLUE_CARBON: 'Blue carbon',
HABITAT_RESTORATION: 'Marine habitat restoration',
COMMUNITY_CAPACITY: 'Coastal community capacity',
SUSTAINABLE_FISHING: 'Sustainable fishing & aquaculture',
CONSUMER_AWARENESS: 'Consumer awareness & education',
OCEAN_ACIDIFICATION: 'Ocean acidification mitigation',
OTHER: 'Other',
}
const categoryLabels: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Start-ups',
}
interface StepReviewProps {
form: UseFormReturn<ApplicationFormData>
programName: string
}
export function StepReview({ form, programName }: StepReviewProps) {
const { formState: { errors }, setValue, watch } = form
const data = watch()
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
return (
<WizardStepContent
title="Review your application"
description="Please review your information before submitting."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{/* Contact Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<User className="h-4 w-4" />
Contact Information
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>{data.contactName}</span>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{data.contactEmail}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
<span>{data.contactPhone}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
</div>
</div>
</div>
{/* Project Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Project Details
</h3>
<div className="space-y-3 text-sm">
<div>
<span className="text-muted-foreground">Project Name:</span>
<p className="font-medium">{data.projectName}</p>
</div>
{data.teamName && (
<div>
<span className="text-muted-foreground">Team Name:</span>
<p className="font-medium">{data.teamName}</p>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">
{categoryLabels[data.competitionCategory]}
</Badge>
</div>
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-muted-foreground" />
<span>{oceanIssueLabels[data.oceanIssue]}</span>
</div>
<div>
<span className="text-muted-foreground">Description:</span>
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
{data.description}
</p>
</div>
</div>
</div>
{/* Team Members Section */}
{data.teamMembers && data.teamMembers.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Team Members ({data.teamMembers.length})
</h3>
<div className="space-y-2">
{data.teamMembers.map((member, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{member.name}</span>
<span className="text-muted-foreground"> - {member.email}</span>
</div>
<Badge variant="outline" className="text-xs">
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Additional Info Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Additional Information
</h3>
<div className="space-y-3 text-sm">
{data.institution && (
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground" />
<span>{data.institution}</span>
</div>
)}
{data.startupCreatedDate && (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center gap-2">
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
<span>
{data.wantsMentorship
? 'Interested in mentorship'
: 'Not interested in mentorship'}
</span>
</div>
{data.referralSource && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span>Heard about us via: {data.referralSource}</span>
</div>
)}
</div>
</div>
{/* GDPR Consent */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-3">
<Checkbox
id="gdprConsent"
checked={data.gdprConsent}
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
className="mt-1"
/>
<div className="flex-1">
<Label htmlFor="gdprConsent" className="text-sm font-medium">
I consent to the processing of my personal data <span className="text-destructive">*</span>
</Label>
<p className="mt-1 text-xs text-muted-foreground">
By submitting this application, I agree that {programName} may process my personal
data in accordance with their privacy policy. My data will be used solely for the
purpose of evaluating my application and communicating with me about the program.
</p>
</div>
</div>
{errors.gdprConsent && (
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
)}
</div>
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { UseFormReturn, useFieldArray } from 'react-hook-form'
import { Plus, Trash2, Users } from 'lucide-react'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { TeamMemberRole } from '@prisma/client'
const roleOptions: { value: TeamMemberRole; label: string }[] = [
{ value: 'MEMBER', label: 'Team Member' },
{ value: 'ADVISOR', label: 'Advisor' },
]
interface StepTeamProps {
form: UseFormReturn<ApplicationFormData>
}
export function StepTeam({ form }: StepTeamProps) {
const { control, register, formState: { errors } } = form
const { fields, append, remove } = useFieldArray({
control,
name: 'teamMembers',
})
const addMember = () => {
append({ name: '', email: '', role: 'MEMBER', title: '' })
}
return (
<WizardStepContent
title="Your team members"
description="Add the other members of your team. They will receive an invitation to create their account."
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-auto max-w-lg space-y-6"
>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
<p className="text-muted-foreground">
No team members added yet.
</p>
<p className="mt-1 text-sm text-muted-foreground">
You can add team members here, or skip this step if you&apos;re applying solo.
</p>
<Button
type="button"
variant="outline"
onClick={addMember}
className="mt-4"
>
<Plus className="mr-2 h-4 w-4" />
Add Team Member
</Button>
</div>
) : (
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{fields.map((field, index) => (
<motion.div
key={field.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="rounded-lg border bg-card p-4"
>
<div className="flex items-start justify-between mb-4">
<h4 className="font-medium text-sm text-muted-foreground">
Team Member {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Name */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.name`}>
Full Name <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.name`}
placeholder="Jane Doe"
{...register(`teamMembers.${index}.name`)}
/>
{errors.teamMembers?.[index]?.name && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.name?.message}
</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.email`}>
Email <span className="text-destructive">*</span>
</Label>
<Input
id={`teamMembers.${index}.email`}
type="email"
placeholder="jane@example.com"
{...register(`teamMembers.${index}.email`)}
/>
{errors.teamMembers?.[index]?.email && (
<p className="text-sm text-destructive">
{errors.teamMembers[index]?.email?.message}
</p>
)}
</div>
{/* Role */}
<div className="space-y-2">
<Label>Role</Label>
<Select
value={form.watch(`teamMembers.${index}.role`)}
onValueChange={(value) =>
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Title/Position */}
<div className="space-y-2">
<Label htmlFor={`teamMembers.${index}.title`}>
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Input
id={`teamMembers.${index}.title`}
placeholder="CTO, Designer, etc."
{...register(`teamMembers.${index}.title`)}
/>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<Button
type="button"
variant="outline"
onClick={addMember}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
Add Another Team Member
</Button>
</div>
)}
</motion.div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { motion } from 'motion/react'
import { Waves, Rocket, GraduationCap } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { CompetitionCategory } from '@prisma/client'
interface CategoryOption {
value: CompetitionCategory
label: string
description: string
icon: typeof Rocket
}
const categories: CategoryOption[] = [
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concepts',
description: 'For students and recent graduates with innovative ocean-focused business ideas',
icon: GraduationCap,
},
{
value: 'STARTUP',
label: 'Start-ups',
description: 'For established companies working on ocean protection solutions',
icon: Rocket,
},
]
interface StepWelcomeProps {
programName: string
programYear: number
value: CompetitionCategory | null
onChange: (value: CompetitionCategory) => void
}
export function StepWelcome({ programName, programYear, value, onChange }: StepWelcomeProps) {
return (
<WizardStepContent>
<div className="flex flex-col items-center text-center">
{/* Logo/Icon */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', duration: 0.8 }}
className="mb-8"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Waves className="h-10 w-10 text-primary" />
</div>
</motion.div>
{/* Welcome text */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
{programName}
</h1>
<p className="mt-2 text-xl text-primary font-semibold">
{programYear} Application
</p>
<p className="mt-4 max-w-md text-muted-foreground">
Join us in protecting our oceans. Select your category to begin.
</p>
</motion.div>
{/* Category selection */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
>
{categories.map((category) => {
const Icon = category.icon
const isSelected = value === category.value
return (
<button
key={category.value}
type="button"
onClick={() => onChange(category.value)}
className={cn(
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border bg-background'
)}
>
<div
className={cn(
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
<Icon className="h-7 w-7" />
</div>
<h3 className="text-lg font-semibold">{category.label}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{category.description}
</p>
{isSelected && (
<motion.div
layoutId="selected-indicator"
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</motion.div>
)}
</button>
)
})}
</motion.div>
</div>
</WizardStepContent>
)
}

View File

@@ -0,0 +1,627 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Papa from 'papaparse'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
AlertCircle,
CheckCircle2,
Upload,
FileSpreadsheet,
ArrowRight,
ArrowLeft,
Loader2,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface CSVImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'upload' | 'mapping' | 'validation' | 'importing' | 'complete'
// Required and optional fields for project import
const PROJECT_FIELDS = [
{ key: 'title', label: 'Title', required: true },
{ key: 'teamName', label: 'Team Name', required: false },
{ key: 'description', label: 'Description', required: false },
{ key: 'tags', label: 'Tags (comma-separated)', required: false },
]
interface ParsedRow {
[key: string]: string
}
interface ValidationError {
row: number
field: string
message: string
}
interface MappedProject {
title: string
teamName?: string
description?: string
tags?: string[]
metadataJson?: Record<string, unknown>
}
export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) {
const router = useRouter()
const [step, setStep] = useState<Step>('upload')
const [file, setFile] = useState<File | null>(null)
const [csvData, setCsvData] = useState<ParsedRow[]>([])
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({})
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([])
const [importProgress, setImportProgress] = useState(0)
const importMutation = trpc.project.importCSV.useMutation({
onSuccess: () => {
setStep('complete')
onSuccess?.()
},
})
// Handle file selection and parsing
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (!selectedFile) return
setFile(selectedFile)
Papa.parse<ParsedRow>(selectedFile, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.data.length === 0) {
return
}
const headers = results.meta.fields || []
setCsvHeaders(headers)
setCsvData(results.data)
// Auto-map columns with exact or similar names
const autoMapping: Record<string, string> = {}
PROJECT_FIELDS.forEach((field) => {
const matchingHeader = headers.find(
(h) =>
h.toLowerCase() === field.key.toLowerCase() ||
h.toLowerCase().replace(/[_\s-]/g, '') ===
field.key.toLowerCase().replace(/[_\s-]/g, '') ||
h.toLowerCase().includes(field.key.toLowerCase())
)
if (matchingHeader) {
autoMapping[field.key] = matchingHeader
}
})
setColumnMapping(autoMapping)
setStep('mapping')
},
error: (error) => {
console.error('CSV parse error:', error)
},
})
}, [])
// Handle column mapping change
const handleMappingChange = (fieldKey: string, csvColumn: string) => {
setColumnMapping((prev) => ({
...prev,
[fieldKey]: csvColumn === '__none__' ? '' : csvColumn,
}))
}
// Validate mapped data
const validateData = useCallback((): {
valid: MappedProject[]
errors: ValidationError[]
} => {
const errors: ValidationError[] = []
const valid: MappedProject[] = []
csvData.forEach((row, index) => {
const rowNum = index + 2 // +2 for header row and 0-indexing
// Check required fields
const titleColumn = columnMapping.title
const title = titleColumn ? row[titleColumn]?.trim() : ''
if (!title) {
errors.push({
row: rowNum,
field: 'title',
message: 'Title is required',
})
return
}
// Build mapped project
const project: MappedProject = {
title,
}
// Optional fields
const teamNameColumn = columnMapping.teamName
if (teamNameColumn && row[teamNameColumn]) {
project.teamName = row[teamNameColumn].trim()
}
const descriptionColumn = columnMapping.description
if (descriptionColumn && row[descriptionColumn]) {
project.description = row[descriptionColumn].trim()
}
const tagsColumn = columnMapping.tags
if (tagsColumn && row[tagsColumn]) {
project.tags = row[tagsColumn]
.split(',')
.map((t) => t.trim())
.filter(Boolean)
}
// Store unmapped columns as metadata
const mappedColumns = new Set(Object.values(columnMapping).filter(Boolean))
const unmappedData: Record<string, unknown> = {}
Object.entries(row).forEach(([key, value]) => {
if (!mappedColumns.has(key) && value?.trim()) {
unmappedData[key] = value.trim()
}
})
if (Object.keys(unmappedData).length > 0) {
project.metadataJson = unmappedData
}
valid.push(project)
})
return { valid, errors }
}, [csvData, columnMapping])
// Proceed to validation step
const handleProceedToValidation = () => {
const { valid, errors } = validateData()
setValidationErrors(errors)
setStep('validation')
}
// Start import
const handleStartImport = async () => {
const { valid } = validateData()
if (valid.length === 0) return
setStep('importing')
setImportProgress(0)
try {
await importMutation.mutateAsync({
roundId,
projects: valid,
})
setImportProgress(100)
} catch (error) {
console.error('Import failed:', error)
setStep('validation')
}
}
// Validation summary
const validationSummary = useMemo(() => {
const { valid, errors } = validateData()
return {
total: csvData.length,
valid: valid.length,
errors: errors.length,
errorRows: [...new Set(errors.map((e) => e.row))].length,
}
}, [csvData, validateData])
// Render step content
const renderStep = () => {
switch (step) {
case 'upload':
return (
<Card>
<CardHeader>
<CardTitle>Upload CSV File</CardTitle>
<CardDescription>
Upload a CSV file containing project data to import into{' '}
<strong>{roundName}</strong>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center transition-colors',
'hover:border-primary/50 cursor-pointer'
)}
onClick={() => document.getElementById('csv-file')?.click()}
>
<FileSpreadsheet className="mx-auto h-12 w-12 text-muted-foreground" />
<p className="mt-2 font-medium">
Drop your CSV file here or click to browse
</p>
<p className="text-sm text-muted-foreground">
Supports .csv files only
</p>
<Input
id="csv-file"
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium mb-2">Expected columns:</p>
<ul className="text-sm text-muted-foreground space-y-1">
{PROJECT_FIELDS.map((field) => (
<li key={field.key}>
<strong>{field.label}</strong>
{field.required && (
<Badge variant="destructive" className="ml-2 text-xs">
Required
</Badge>
)}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
)
case 'mapping':
return (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map your CSV columns to project fields. {csvData.length} rows
found.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* File info */}
<div className="flex items-center gap-3 rounded-lg bg-muted p-3">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{file?.name}</p>
<p className="text-sm text-muted-foreground">
{csvData.length} rows, {csvHeaders.length} columns
</p>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => {
setFile(null)
setCsvData([])
setCsvHeaders([])
setColumnMapping({})
setStep('upload')
}}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Column mapping */}
<div className="space-y-4">
{PROJECT_FIELDS.map((field) => (
<div
key={field.key}
className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"
>
<div className="sm:w-48 flex items-center gap-2">
<Label className="font-medium">{field.label}</Label>
{field.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</div>
<Select
value={columnMapping[field.key] || '__none__'}
onValueChange={(v) => handleMappingChange(field.key, v)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select column" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground">
-- Not mapped --
</span>
</SelectItem>
{csvHeaders.map((header) => (
<SelectItem key={header} value={header}>
{header}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{/* Preview */}
<div className="space-y-2">
<Label className="font-medium">Preview (first 5 rows)</Label>
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{PROJECT_FIELDS.filter(
(f) => columnMapping[f.key]
).map((field) => (
<TableHead key={field.key}>{field.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{csvData.slice(0, 5).map((row, index) => (
<TableRow key={index}>
{PROJECT_FIELDS.filter(
(f) => columnMapping[f.key]
).map((field) => (
<TableCell key={field.key} className="max-w-[200px] truncate">
{row[columnMapping[field.key]] || '-'}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Actions */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => {
setFile(null)
setCsvData([])
setCsvHeaders([])
setColumnMapping({})
setStep('upload')
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleProceedToValidation}
disabled={!columnMapping.title}
>
Validate Data
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
case 'validation':
return (
<Card>
<CardHeader>
<CardTitle>Validation Summary</CardTitle>
<CardDescription>
Review the validation results before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg bg-muted p-4 text-center">
<p className="text-3xl font-bold">{validationSummary.total}</p>
<p className="text-sm text-muted-foreground">Total Rows</p>
</div>
<div className="rounded-lg bg-green-500/10 p-4 text-center">
<p className="text-3xl font-bold text-green-600">
{validationSummary.valid}
</p>
<p className="text-sm text-muted-foreground">Valid Projects</p>
</div>
<div className="rounded-lg bg-red-500/10 p-4 text-center">
<p className="text-3xl font-bold text-red-600">
{validationSummary.errorRows}
</p>
<p className="text-sm text-muted-foreground">Rows with Errors</p>
</div>
</div>
{/* Errors list */}
{validationErrors.length > 0 && (
<div className="space-y-2">
<Label className="font-medium text-destructive">
Validation Errors
</Label>
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4 max-h-60 overflow-y-auto">
{validationErrors.slice(0, 20).map((error, index) => (
<div
key={index}
className="flex items-start gap-2 text-sm py-1"
>
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<span>
<strong>Row {error.row}:</strong> {error.message}
</span>
</div>
))}
{validationErrors.length > 20 && (
<p className="text-sm text-muted-foreground mt-2">
... and {validationErrors.length - 20} more errors
</p>
)}
</div>
</div>
)}
{/* Success message */}
{validationSummary.valid > 0 && validationErrors.length === 0 && (
<div className="flex items-center gap-2 rounded-lg bg-green-500/10 p-4 text-green-700">
<CheckCircle2 className="h-5 w-5" />
<span>All {validationSummary.valid} projects are valid and ready to import!</span>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep('mapping')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Mapping
</Button>
<Button
onClick={handleStartImport}
disabled={validationSummary.valid === 0 || importMutation.isPending}
>
{importMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Import {validationSummary.valid} Projects
</Button>
</div>
{/* Import error */}
{importMutation.error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>
Import failed: {importMutation.error.message}
</span>
</div>
)}
</CardContent>
</Card>
)
case 'importing':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we process your data
</p>
<Progress value={importProgress} className="mt-4 w-48" />
</CardContent>
</Card>
)
case 'complete':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<p className="mt-4 text-xl font-semibold">Import Complete!</p>
<p className="text-muted-foreground">
Successfully imported {validationSummary.valid} projects into{' '}
<strong>{roundName}</strong>
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" onClick={() => router.back()}>
Back to Projects
</Button>
<Button
onClick={() => {
setFile(null)
setCsvData([])
setCsvHeaders([])
setColumnMapping({})
setValidationErrors([])
setStep('upload')
}}
>
Import More
</Button>
</div>
</CardContent>
</Card>
)
}
}
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center justify-center gap-2">
{(['upload', 'mapping', 'validation', 'complete'] as const).map(
(s, index) => (
<div key={s} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 mx-1',
step === s ||
['upload', 'mapping', 'validation', 'complete'].indexOf(
step
) >
['upload', 'mapping', 'validation', 'complete'].indexOf(s)
? 'bg-primary'
: 'bg-muted'
)}
/>
)}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
step === s
? 'bg-primary text-primary-foreground'
: ['upload', 'mapping', 'validation', 'complete'].indexOf(
step
) > ['upload', 'mapping', 'validation', 'complete'].indexOf(s)
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{index + 1}
</div>
</div>
)
)}
</div>
{renderStep()}
</div>
)
}

View File

@@ -0,0 +1,516 @@
'use client'
import { useState, useCallback } from 'react'
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 { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Plus,
Trash2,
ChevronUp,
ChevronDown,
Edit,
Eye,
GripVertical,
Check,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export interface Criterion {
id: string
label: string
description?: string
scale: number // 5 or 10
weight?: number
required: boolean
}
interface EvaluationFormBuilderProps {
initialCriteria?: Criterion[]
onChange: (criteria: Criterion[]) => void
disabled?: boolean
}
function generateId(): string {
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function createDefaultCriterion(): Criterion {
return {
id: generateId(),
label: '',
description: '',
scale: 5,
weight: 1,
required: true,
}
}
export function EvaluationFormBuilder({
initialCriteria = [],
onChange,
disabled = false,
}: EvaluationFormBuilderProps) {
const [criteria, setCriteria] = useState<Criterion[]>(initialCriteria)
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState<Criterion | null>(null)
// Update parent when criteria change
const updateCriteria = useCallback(
(newCriteria: Criterion[]) => {
setCriteria(newCriteria)
onChange(newCriteria)
},
[onChange]
)
// Add new criterion
const addCriterion = useCallback(() => {
const newCriterion = createDefaultCriterion()
const newCriteria = [...criteria, newCriterion]
updateCriteria(newCriteria)
setEditingId(newCriterion.id)
setEditDraft(newCriterion)
}, [criteria, updateCriteria])
// Delete criterion
const deleteCriterion = useCallback(
(id: string) => {
updateCriteria(criteria.filter((c) => c.id !== id))
if (editingId === id) {
setEditingId(null)
setEditDraft(null)
}
},
[criteria, editingId, updateCriteria]
)
// Move criterion up/down
const moveCriterion = useCallback(
(id: string, direction: 'up' | 'down') => {
const index = criteria.findIndex((c) => c.id === id)
if (index === -1) return
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= criteria.length) return
const newCriteria = [...criteria]
const [removed] = newCriteria.splice(index, 1)
newCriteria.splice(newIndex, 0, removed)
updateCriteria(newCriteria)
},
[criteria, updateCriteria]
)
// Start editing
const startEditing = useCallback((criterion: Criterion) => {
setEditingId(criterion.id)
setEditDraft({ ...criterion })
}, [])
// Cancel editing
const cancelEditing = useCallback(() => {
// If it's a new criterion with no label, remove it
if (editDraft && !editDraft.label.trim()) {
updateCriteria(criteria.filter((c) => c.id !== editDraft.id))
}
setEditingId(null)
setEditDraft(null)
}, [editDraft, criteria, updateCriteria])
// Save editing
const saveEditing = useCallback(() => {
if (!editDraft || !editDraft.label.trim()) return
updateCriteria(
criteria.map((c) => (c.id === editDraft.id ? editDraft : c))
)
setEditingId(null)
setEditDraft(null)
}, [editDraft, criteria, updateCriteria])
// Update edit draft
const updateDraft = useCallback(
(updates: Partial<Criterion>) => {
if (!editDraft) return
setEditDraft({ ...editDraft, ...updates })
},
[editDraft]
)
return (
<div className="space-y-4">
{/* Criteria list */}
{criteria.length > 0 ? (
<div className="space-y-2">
{criteria.map((criterion, index) => {
const isEditing = editingId === criterion.id
return (
<div
key={criterion.id}
className={cn(
'rounded-lg border transition-colors',
isEditing ? 'border-primary bg-muted/30' : 'bg-background',
disabled && 'opacity-60'
)}
>
{isEditing && editDraft ? (
// Edit mode
<div className="p-4 space-y-4">
<div className="space-y-2">
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
<Input
id={`label-${criterion.id}`}
value={editDraft.label}
onChange={(e) => updateDraft({ label: e.target.value })}
placeholder="e.g., Innovation"
disabled={disabled}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor={`description-${criterion.id}`}>
Description (optional)
</Label>
<Textarea
id={`description-${criterion.id}`}
value={editDraft.description || ''}
onChange={(e) => updateDraft({ description: e.target.value })}
placeholder="Help jurors understand this criterion..."
rows={2}
maxLength={500}
disabled={disabled}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
<Select
value={String(editDraft.scale)}
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
disabled={disabled}
>
<SelectTrigger id={`scale-${criterion.id}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`weight-${criterion.id}`}>
Weight (optional)
</Label>
<Input
id={`weight-${criterion.id}`}
type="number"
min={0}
step={0.1}
value={editDraft.weight ?? ''}
onChange={(e) =>
updateDraft({
weight: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label>Required</Label>
<div className="flex items-center h-10">
<Switch
checked={editDraft.required}
onCheckedChange={(checked) =>
updateDraft({ required: checked })
}
disabled={disabled}
/>
</div>
</div>
</div>
{/* Edit actions */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelEditing}
disabled={disabled}
>
<X className="mr-1 h-4 w-4" />
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={saveEditing}
disabled={disabled || !editDraft.label.trim()}
>
<Check className="mr-1 h-4 w-4" />
Save
</Button>
</div>
</div>
) : (
// View mode
<div className="flex items-center gap-3 p-3">
{/* Drag handle / position indicator */}
<div className="text-muted-foreground/50">
<GripVertical className="h-4 w-4" />
</div>
{/* Criterion info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate">
{criterion.label || '(Untitled)'}
</span>
<Badge variant="secondary" className="shrink-0 text-xs">
1-{criterion.scale}
</Badge>
{criterion.weight && criterion.weight !== 1 && (
<Badge variant="outline" className="shrink-0 text-xs">
{criterion.weight}x
</Badge>
)}
{criterion.required && (
<Badge variant="default" className="shrink-0 text-xs">
Required
</Badge>
)}
</div>
{criterion.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
{criterion.description}
</p>
)}
</div>
{/* Actions */}
{!disabled && (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Move criterion up"
onClick={() => moveCriterion(criterion.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Move criterion down"
onClick={() => moveCriterion(criterion.id, 'down')}
disabled={index === criteria.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Edit criterion"
onClick={() => startEditing(criterion)}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
aria-label="Delete criterion"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete criterion?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{criterion.label}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteCriterion(criterion.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-muted-foreground">
No evaluation criteria defined yet.
</p>
</div>
)}
{/* Actions */}
{!disabled && (
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={addCriterion}
disabled={editingId !== null}
>
<Plus className="mr-1 h-4 w-4" />
Add Criterion
</Button>
{criteria.length > 0 && (
<PreviewDialog criteria={criteria} />
)}
</div>
)}
</div>
)
}
// Preview dialog showing how the evaluation form will look
function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="sm">
<Eye className="mr-1 h-4 w-4" />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Evaluation Form Preview</DialogTitle>
<DialogDescription>
This is how the evaluation form will appear to jurors.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{criteria.map((criterion) => (
<Card key={criterion.id}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
{criterion.label}
{criterion.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</CardTitle>
{criterion.description && (
<CardDescription>{criterion.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary/30 rounded-full"
style={{ width: '50%' }}
/>
</div>
<span className="text-xs text-muted-foreground w-4">
{criterion.scale}
</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map(
(num) => (
<div
key={num}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
num <= Math.ceil(criterion.scale / 2)
? 'bg-primary/20 text-primary'
: 'bg-muted'
)}
>
{num}
</div>
)
)}
</div>
</div>
</CardContent>
</Card>
))}
{criteria.length === 0 && (
<p className="text-center text-muted-foreground py-8">
No criteria to preview.
</p>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,600 @@
'use client'
import { useState, useEffect, useCallback, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Loader2,
Save,
Send,
CheckCircle2,
AlertCircle,
Clock,
Star,
ThumbsUp,
ThumbsDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// Define criterion type from the evaluation form JSON
interface Criterion {
id: string
label: string
description?: string
scale: number // max value (e.g., 5 or 10)
weight?: number
required?: boolean
}
interface EvaluationFormProps {
assignmentId: string
evaluationId: string | null
projectTitle: string
criteria: Criterion[]
initialData?: {
criterionScoresJson: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
status: string
}
isVotingOpen: boolean
deadline?: Date | null
}
const createEvaluationSchema = (criteria: Criterion[]) =>
z.object({
criterionScores: z.record(z.number()),
globalScore: z.number().int().min(1).max(10),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
})
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
export function EvaluationForm({
assignmentId,
evaluationId,
projectTitle,
criteria,
initialData,
isVotingOpen,
deadline,
}: EvaluationFormProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [lastSaved, setLastSaved] = useState<Date | null>(null)
// Initialize criterion scores with existing data or defaults
const defaultCriterionScores: Record<string, number> = {}
criteria.forEach((c) => {
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
})
const form = useForm<EvaluationFormData>({
resolver: zodResolver(createEvaluationSchema(criteria)),
defaultValues: {
criterionScores: defaultCriterionScores,
globalScore: initialData?.globalScore ?? 5,
binaryDecision: initialData?.binaryDecision ?? false,
feedbackText: initialData?.feedbackText ?? '',
},
mode: 'onChange',
})
const { watch, handleSubmit, control, formState } = form
const { errors, isValid, isDirty } = formState
// tRPC mutations
const startEvaluation = trpc.evaluation.start.useMutation()
const autosave = trpc.evaluation.autosave.useMutation()
const submit = trpc.evaluation.submit.useMutation()
const utils = trpc.useUtils()
// State to track the current evaluation ID (might be created on first autosave)
const [currentEvaluationId, setCurrentEvaluationId] = useState<string | null>(evaluationId)
// Create evaluation if it doesn't exist
useEffect(() => {
if (!currentEvaluationId && isVotingOpen) {
startEvaluation.mutate(
{ assignmentId },
{
onSuccess: (data) => {
setCurrentEvaluationId(data.id)
},
}
)
}
}, [assignmentId, currentEvaluationId, isVotingOpen])
// Debounced autosave function
const debouncedAutosave = useDebouncedCallback(
async (data: EvaluationFormData) => {
if (!currentEvaluationId || !isVotingOpen) return
setAutosaveStatus('saving')
try {
await autosave.mutateAsync({
id: currentEvaluationId,
criterionScoresJson: data.criterionScores,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
feedbackText: data.feedbackText,
})
setAutosaveStatus('saved')
setLastSaved(new Date())
// Reset to idle after a few seconds
setTimeout(() => setAutosaveStatus('idle'), 3000)
} catch (error) {
console.error('Autosave failed:', error)
setAutosaveStatus('error')
}
},
3000 // 3 second debounce
)
// Watch form values and trigger autosave
const watchedValues = watch()
useEffect(() => {
if (isDirty && isVotingOpen) {
debouncedAutosave(watchedValues)
}
}, [watchedValues, isDirty, isVotingOpen, debouncedAutosave])
// Submit handler
const onSubmit = async (data: EvaluationFormData) => {
if (!currentEvaluationId) return
try {
await submit.mutateAsync({
id: currentEvaluationId,
criterionScoresJson: data.criterionScores,
globalScore: data.globalScore,
binaryDecision: data.binaryDecision,
feedbackText: data.feedbackText,
})
// Invalidate queries and redirect
utils.assignment.myAssignments.invalidate()
startTransition(() => {
router.push(`/jury/projects/${assignmentId.split('-')[0]}/evaluation`)
router.refresh()
})
} catch (error) {
console.error('Submit failed:', error)
}
}
const isSubmitted = initialData?.status === 'SUBMITTED' || initialData?.status === 'LOCKED'
const isReadOnly = isSubmitted || !isVotingOpen
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Status bar */}
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
{projectTitle}
</h2>
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
</div>
{!isReadOnly && (
<div className="flex items-center gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
disabled={!isValid || submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit Evaluation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
<AlertDialogDescription>
Once submitted, you cannot edit your evaluation. Please review
your scores and feedback before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit(onSubmit)}
disabled={submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Confirm Submit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{isReadOnly && (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
{isSubmitted ? 'Submitted' : 'Read Only'}
</Badge>
)}
</div>
</div>
{/* Criteria scoring */}
{criteria.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
<CardDescription>
Rate the project on each criterion below
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{criteria.map((criterion) => (
<CriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
/>
))}
</CardContent>
</Card>
)}
{/* Global score */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Score</CardTitle>
<CardDescription>
Rate the project overall on a scale of 1 to 10
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="globalScore"
control={control}
render={({ field }) => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Poor</span>
<span className="text-4xl font-bold">{field.value}</span>
<span className="text-sm text-muted-foreground">Excellent</span>
</div>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
disabled={isReadOnly}
className="py-4"
/>
<div className="flex justify-between">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<button
key={num}
type="button"
onClick={() => !isReadOnly && field.onChange(num)}
disabled={isReadOnly}
className={cn(
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
field.value === num
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80',
isReadOnly && 'opacity-50 cursor-not-allowed'
)}
>
{num}
</button>
))}
</div>
</div>
)}
/>
</CardContent>
</Card>
{/* Binary decision */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendation</CardTitle>
<CardDescription>
Do you recommend this project to advance to the next round?
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="binaryDecision"
control={control}
render={({ field }) => (
<div className="flex gap-4">
<Button
type="button"
variant={field.value ? 'default' : 'outline'}
className={cn(
'flex-1 h-20',
field.value && 'bg-green-600 hover:bg-green-700'
)}
onClick={() => !isReadOnly && field.onChange(true)}
disabled={isReadOnly}
>
<ThumbsUp className="mr-2 h-6 w-6" />
Yes, Recommend
</Button>
<Button
type="button"
variant={!field.value ? 'default' : 'outline'}
className={cn(
'flex-1 h-20',
!field.value && 'bg-red-600 hover:bg-red-700'
)}
onClick={() => !isReadOnly && field.onChange(false)}
disabled={isReadOnly}
>
<ThumbsDown className="mr-2 h-6 w-6" />
No, Do Not Recommend
</Button>
</div>
)}
/>
</CardContent>
</Card>
{/* Feedback text */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Written Feedback</CardTitle>
<CardDescription>
Provide constructive feedback for this project (minimum 10 characters)
</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="feedbackText"
control={control}
render={({ field }) => (
<div className="space-y-2">
<Textarea
{...field}
placeholder="Share your thoughts on the project's strengths, weaknesses, and potential..."
rows={6}
maxLength={5000}
disabled={isReadOnly}
className={cn(
errors.feedbackText && 'border-destructive'
)}
/>
<div className="flex items-center justify-between">
{errors.feedbackText ? (
<p className="text-sm text-destructive">
{errors.feedbackText.message}
</p>
) : (
<p className="text-sm text-muted-foreground">
{field.value.length} characters
</p>
)}
</div>
</div>
)}
/>
</CardContent>
</Card>
{/* Error display */}
{submit.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">
{submit.error.message || 'Failed to submit evaluation. Please try again.'}
</p>
</CardContent>
</Card>
)}
{/* Bottom submit button for mobile */}
{!isReadOnly && (
<div className="flex justify-end pb-safe">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
size="lg"
disabled={!isValid || submit.isPending}
className="w-full sm:w-auto"
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit Evaluation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
<AlertDialogDescription>
Once submitted, you cannot edit your evaluation. Please review
your scores and feedback before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit(onSubmit)}
disabled={submit.isPending}
>
{submit.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Confirm Submit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</form>
)
}
// Criterion field component
function CriterionField({
criterion,
control,
disabled,
}: {
criterion: Criterion
control: any
disabled: boolean
}) {
return (
<Controller
name={`criterionScores.${criterion.id}`}
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base font-medium">{criterion.label}</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<Badge variant="secondary" className="shrink-0">
{field.value}/{criterion.scale}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<Slider
min={1}
max={criterion.scale}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
disabled={disabled}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
</div>
{/* Visual rating buttons */}
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
<button
key={num}
type="button"
onClick={() => !disabled && field.onChange(num)}
disabled={disabled}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
field.value === num
? 'bg-primary text-primary-foreground'
: field.value > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{num}
</button>
))}
</div>
</div>
)}
/>
)
}
// Autosave indicator component
function AutosaveIndicator({
status,
lastSaved,
}: {
status: 'idle' | 'saving' | 'saved' | 'error'
lastSaved: Date | null
}) {
if (status === 'idle' && lastSaved) {
return (
<span className="text-xs text-muted-foreground hidden sm:inline">
Saved
</span>
)
}
if (status === 'saving') {
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="hidden sm:inline">Saving...</span>
</span>
)
}
if (status === 'saved') {
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<CheckCircle2 className="h-3 w-3" />
<span className="hidden sm:inline">Saved</span>
</span>
)
}
if (status === 'error') {
return (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
<span className="hidden sm:inline">Save failed</span>
</span>
)
}
return null
}

View File

@@ -0,0 +1,326 @@
'use client'
import * as React from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
export interface WizardStep {
id: string
title: string
description?: string
isOptional?: boolean
}
interface FormWizardContextValue {
currentStep: number
totalSteps: number
steps: WizardStep[]
goToStep: (step: number) => void
nextStep: () => void
prevStep: () => void
isFirstStep: boolean
isLastStep: boolean
canGoNext: boolean
setCanGoNext: (can: boolean) => void
}
const FormWizardContext = React.createContext<FormWizardContextValue | null>(null)
export function useFormWizard() {
const context = React.useContext(FormWizardContext)
if (!context) {
throw new Error('useFormWizard must be used within a FormWizard')
}
return context
}
interface FormWizardProps {
steps: WizardStep[]
children: React.ReactNode
onComplete?: () => void | Promise<void>
isSubmitting?: boolean
submitLabel?: string
className?: string
showStepIndicator?: boolean
allowStepNavigation?: boolean
}
export function FormWizard({
steps,
children,
onComplete,
isSubmitting = false,
submitLabel = 'Submit',
className,
showStepIndicator = true,
allowStepNavigation = false,
}: FormWizardProps) {
const [currentStep, setCurrentStep] = React.useState(0)
const [canGoNext, setCanGoNext] = React.useState(true)
const [direction, setDirection] = React.useState(0) // -1 for back, 1 for forward
const totalSteps = steps.length
const isFirstStep = currentStep === 0
const isLastStep = currentStep === totalSteps - 1
const goToStep = React.useCallback((step: number) => {
if (step >= 0 && step < totalSteps) {
setDirection(step > currentStep ? 1 : -1)
setCurrentStep(step)
}
}, [currentStep, totalSteps])
const nextStep = React.useCallback(() => {
if (currentStep < totalSteps - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}, [currentStep, totalSteps])
const prevStep = React.useCallback(() => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}, [currentStep])
const handleNext = async () => {
if (isLastStep && onComplete) {
await onComplete()
} else {
nextStep()
}
}
const contextValue: FormWizardContextValue = {
currentStep,
totalSteps,
steps,
goToStep,
nextStep,
prevStep,
isFirstStep,
isLastStep,
canGoNext,
setCanGoNext,
}
const childrenArray = React.Children.toArray(children)
const currentChild = childrenArray[currentStep]
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
}
return (
<FormWizardContext.Provider value={contextValue}>
<div className={cn('flex min-h-[600px] flex-col', className)}>
{showStepIndicator && (
<StepIndicator
steps={steps}
currentStep={currentStep}
allowNavigation={allowStepNavigation}
onStepClick={allowStepNavigation ? goToStep : undefined}
/>
)}
<div className="relative flex-1 overflow-hidden">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="absolute inset-0"
>
<div className="h-full px-1 py-6">
{currentChild}
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={isFirstStep || isSubmitting}
className={cn(isFirstStep && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
type="button"
onClick={handleNext}
disabled={!canGoNext || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : isLastStep ? (
<>
<Check className="mr-2 h-4 w-4" />
{submitLabel}
</>
) : (
<>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</FormWizardContext.Provider>
)
}
interface StepIndicatorProps {
steps: WizardStep[]
currentStep: number
allowNavigation?: boolean
onStepClick?: (step: number) => void
}
export function StepIndicator({
steps,
currentStep,
allowNavigation = false,
onStepClick,
}: StepIndicatorProps) {
return (
<div className="mb-8">
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Step {currentStep + 1} of {steps.length}</span>
<span>{Math.round(((currentStep + 1) / steps.length) * 100)}% complete</span>
</div>
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-secondary">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/80"
initial={{ width: 0 }}
animate={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
/>
</div>
</div>
{/* Step indicators */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = index < currentStep
const isCurrent = index === currentStep
const isClickable = allowNavigation && (isCompleted || isCurrent)
return (
<React.Fragment key={step.id}>
<button
type="button"
onClick={() => isClickable && onStepClick?.(index)}
disabled={!isClickable}
className={cn(
'flex flex-col items-center gap-2',
isClickable && 'cursor-pointer',
!isClickable && 'cursor-default'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
isCompleted && 'border-primary bg-primary text-primary-foreground',
isCurrent && 'border-primary text-primary',
!isCompleted && !isCurrent && 'border-muted text-muted-foreground'
)}
>
{isCompleted ? (
<Check className="h-5 w-5" />
) : (
<span>{index + 1}</span>
)}
</div>
<span
className={cn(
'hidden text-xs font-medium md:block',
isCurrent && 'text-primary',
!isCurrent && 'text-muted-foreground'
)}
>
{step.title}
</span>
</button>
{index < steps.length - 1 && (
<div
className={cn(
'h-0.5 flex-1 mx-2',
index < currentStep ? 'bg-primary' : 'bg-muted'
)}
/>
)}
</React.Fragment>
)
})}
</div>
</div>
)
}
interface WizardStepContentProps {
children: React.ReactNode
title?: string
description?: string
className?: string
}
export function WizardStepContent({
children,
title,
description,
className,
}: WizardStepContentProps) {
return (
<div className={cn('flex h-full flex-col', className)}>
{(title || description) && (
<div className="mb-8 text-center">
{title && (
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
{title}
</h2>
)}
{description && (
<p className="mt-2 text-muted-foreground">
{description}
</p>
)}
</div>
)}
<div className="flex-1">
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,489 @@
'use client'
import { useState } from 'react'
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
Database,
} from 'lucide-react'
interface NotionImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function NotionImportForm({
roundId,
roundName,
onSuccess,
}: NotionImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [databaseId, setDatabaseId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ recordId: string; error: string }>
} | null>(null)
const testConnection = trpc.notionImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.notionImport.getDatabaseSchema.useQuery(
{ apiKey, databaseId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.notionImport.previewData.useQuery(
{ apiKey, databaseId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.notionImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !databaseId) {
toast.error('Please enter both API key and database ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
databaseId,
roundId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const properties = schema?.properties || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Connect to Notion
</CardTitle>
<CardDescription>
Enter your Notion API key and database ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Notion API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="secret_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="databaseId">Database ID</Label>
<Input
id="databaseId"
placeholder="abc123..."
value={databaseId}
onChange={(e) => setDatabaseId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Notion database URL
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !databaseId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Columns</CardTitle>
<CardDescription>
Map Notion properties to project fields. Database: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property" />
</SelectTrigger>
<SelectContent>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}{' '}
<span className="text-muted-foreground">({p.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select property (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{properties
.filter((p) => p.type === 'multi_select' || p.type === 'select')
.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped columns in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} records before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Tags</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2">
{String(record.properties[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record.properties[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.tags && record.properties[mappings.tags]
? (
record.properties[mappings.tags] as string[]
).map((tag, j) => (
<Badge key={j} variant="secondary" className="mr-1">
{tag}
</Badge>
))
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all records from the Notion database into{' '}
<strong>{roundName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Records
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing projects...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Notion
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} records skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.recordId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,513 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Filter, ClipboardCheck, Zap, Info } from 'lucide-react'
import {
type FilteringRoundSettings,
type EvaluationRoundSettings,
type LiveEventRoundSettings,
defaultFilteringSettings,
defaultEvaluationSettings,
defaultLiveEventSettings,
roundTypeLabels,
roundTypeDescriptions,
} from '@/types/round-settings'
interface RoundTypeSettingsProps {
roundType: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'
onRoundTypeChange: (type: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT') => void
settings: Record<string, unknown>
onSettingsChange: (settings: Record<string, unknown>) => void
}
const roundTypeIcons = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
export function RoundTypeSettings({
roundType,
onRoundTypeChange,
settings,
onSettingsChange,
}: RoundTypeSettingsProps) {
const Icon = roundTypeIcons[roundType]
// Get typed settings with defaults
const getFilteringSettings = (): FilteringRoundSettings => ({
...defaultFilteringSettings,
...(settings as Partial<FilteringRoundSettings>),
})
const getEvaluationSettings = (): EvaluationRoundSettings => ({
...defaultEvaluationSettings,
...(settings as Partial<EvaluationRoundSettings>),
})
const getLiveEventSettings = (): LiveEventRoundSettings => ({
...defaultLiveEventSettings,
...(settings as Partial<LiveEventRoundSettings>),
})
const updateSetting = <T extends Record<string, unknown>>(
key: keyof T,
value: T[keyof T]
) => {
onSettingsChange({ ...settings, [key]: value })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
Round Type & Configuration
</CardTitle>
<CardDescription>
Configure the type and behavior for this round
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Round Type Selector */}
<div className="space-y-2">
<Label>Round Type</Label>
<Select value={roundType} onValueChange={(v) => onRoundTypeChange(v as typeof roundType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
return (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
<TypeIcon className="h-4 w-4" />
{roundTypeLabels[type]}
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{roundTypeDescriptions[roundType]}
</p>
</div>
{/* Type-specific settings */}
{roundType === 'FILTERING' && (
<FilteringSettings
settings={getFilteringSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
{roundType === 'EVALUATION' && (
<EvaluationSettings
settings={getEvaluationSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
{roundType === 'LIVE_EVENT' && (
<LiveEventSettings
settings={getLiveEventSettings()}
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
/>
)}
</CardContent>
</Card>
)
}
// Filtering Round Settings
function FilteringSettings({
settings,
onChange,
}: {
settings: FilteringRoundSettings
onChange: (settings: FilteringRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Filtering Settings</h4>
{/* Target Advancing */}
<div className="space-y-2">
<Label htmlFor="targetAdvancing">Target Projects to Advance</Label>
<Input
id="targetAdvancing"
type="number"
min="1"
value={settings.targetAdvancing}
onChange={(e) =>
onChange({ ...settings, targetAdvancing: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of projects to advance to the next round
</p>
</div>
{/* Auto-elimination */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Elimination</Label>
<p className="text-sm text-muted-foreground">
Automatically flag projects below threshold
</p>
</div>
<Switch
checked={settings.autoEliminationEnabled}
onCheckedChange={(v) =>
onChange({ ...settings, autoEliminationEnabled: v })
}
/>
</div>
{settings.autoEliminationEnabled && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="threshold">Score Threshold</Label>
<Input
id="threshold"
type="number"
min="1"
max="10"
step="0.5"
value={settings.autoEliminationThreshold}
onChange={(e) =>
onChange({
...settings,
autoEliminationThreshold: parseFloat(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Projects averaging below this score will be flagged
</p>
</div>
<div className="space-y-2">
<Label htmlFor="minReviews">Minimum Reviews</Label>
<Input
id="minReviews"
type="number"
min="1"
value={settings.autoEliminationMinReviews}
onChange={(e) =>
onChange({
...settings,
autoEliminationMinReviews: parseInt(e.target.value) || 1,
})
}
/>
<p className="text-xs text-muted-foreground">
Min reviews before auto-elimination applies
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Auto-elimination only flags projects for review. Final decisions require
admin approval.
</AlertDescription>
</Alert>
</div>
)}
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Evaluation Round Settings
function EvaluationSettings({
settings,
onChange,
}: {
settings: EvaluationRoundSettings
onChange: (settings: EvaluationRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Evaluation Settings</h4>
{/* Target Finalists */}
<div className="space-y-2">
<Label htmlFor="targetFinalists">Target Finalists</Label>
<Input
id="targetFinalists"
type="number"
min="1"
value={settings.targetFinalists}
onChange={(e) =>
onChange({ ...settings, targetFinalists: parseInt(e.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
The target number of finalists to select
</p>
</div>
{/* Requirements */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Requirements</h5>
<div className="flex items-center justify-between">
<div>
<Label>Require All Criteria</Label>
<p className="text-sm text-muted-foreground">
Jury must score all criteria before submission
</p>
</div>
<Switch
checked={settings.requireAllCriteria}
onCheckedChange={(v) =>
onChange({ ...settings, requireAllCriteria: v })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Detailed Criteria Required</Label>
<p className="text-sm text-muted-foreground">
Use detailed evaluation criteria
</p>
</div>
<Switch
checked={settings.detailedCriteriaRequired}
onCheckedChange={(v) =>
onChange({ ...settings, detailedCriteriaRequired: v })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minFeedback">Minimum Feedback Length</Label>
<Input
id="minFeedback"
type="number"
min="0"
value={settings.minimumFeedbackLength}
onChange={(e) =>
onChange({
...settings,
minimumFeedbackLength: parseInt(e.target.value) || 0,
})
}
/>
<p className="text-xs text-muted-foreground">
Minimum characters for feedback comments (0 = optional)
</p>
</div>
</div>
{/* Display Options */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center justify-between">
<Label htmlFor="showAverage">Show Average Score</Label>
<Switch
id="showAverage"
checked={settings.showAverageScore}
onCheckedChange={(v) =>
onChange({ ...settings, showAverageScore: v })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRanking">Show Ranking</Label>
<Switch
id="showRanking"
checked={settings.showRanking}
onCheckedChange={(v) =>
onChange({ ...settings, showRanking: v })
}
/>
</div>
</div>
</div>
</div>
)
}
// Live Event Round Settings
function LiveEventSettings({
settings,
onChange,
}: {
settings: LiveEventRoundSettings
onChange: (settings: LiveEventRoundSettings) => void
}) {
return (
<div className="space-y-6 border-t pt-4">
<h4 className="font-medium">Live Event Settings</h4>
{/* Presentation */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Presentation</h5>
<div className="space-y-2">
<Label htmlFor="duration">Presentation Duration (minutes)</Label>
<Input
id="duration"
type="number"
min="1"
max="60"
value={settings.presentationDurationMinutes}
onChange={(e) =>
onChange({
...settings,
presentationDurationMinutes: parseInt(e.target.value) || 5,
})
}
/>
</div>
</div>
{/* Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Voting</h5>
<div className="space-y-2">
<Label htmlFor="votingWindow">Voting Window (seconds)</Label>
<Input
id="votingWindow"
type="number"
min="10"
max="300"
value={settings.votingWindowSeconds}
onChange={(e) =>
onChange({
...settings,
votingWindowSeconds: parseInt(e.target.value) || 30,
})
}
/>
<p className="text-xs text-muted-foreground">
Duration of the voting window after each presentation
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Allow Vote Change</Label>
<p className="text-sm text-muted-foreground">
Allow jury to change their vote during the window
</p>
</div>
<Switch
checked={settings.allowVoteChange}
onCheckedChange={(v) =>
onChange({ ...settings, allowVoteChange: v })
}
/>
</div>
</div>
{/* Display */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5>
<div className="flex items-center justify-between">
<div>
<Label>Show Live Scores</Label>
<p className="text-sm text-muted-foreground">
Display scores in real-time during the event
</p>
</div>
<Switch
checked={settings.showLiveScores}
onCheckedChange={(v) =>
onChange({ ...settings, showLiveScores: v })
}
/>
</div>
<div className="space-y-2">
<Label>Display Mode</Label>
<Select
value={settings.displayMode}
onValueChange={(v) =>
onChange({
...settings,
displayMode: v as 'SCORES' | 'RANKING' | 'NONE',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SCORES">Show Scores</SelectItem>
<SelectItem value="RANKING">Show Ranking</SelectItem>
<SelectItem value="NONE">Hide Until End</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How results are displayed on the public screen
</p>
</div>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Presentation order can be configured in the Live Voting section once the round
is activated.
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -0,0 +1,516 @@
'use client'
import { useState } from 'react'
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { toast } from 'sonner'
import {
CheckCircle,
AlertCircle,
Loader2,
ArrowRight,
ArrowLeft,
FileText,
} from 'lucide-react'
interface TypeformImportFormProps {
roundId: string
roundName: string
onSuccess?: () => void
}
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
export function TypeformImportForm({
roundId,
roundName,
onSuccess,
}: TypeformImportFormProps) {
const [step, setStep] = useState<Step>('connect')
const [apiKey, setApiKey] = useState('')
const [formId, setFormId] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
const [connectionError, setConnectionError] = useState<string | null>(null)
// Mapping state
const [mappings, setMappings] = useState({
title: '',
teamName: '',
description: '',
tags: '',
email: '',
})
const [includeUnmapped, setIncludeUnmapped] = useState(true)
// Results
const [importResults, setImportResults] = useState<{
imported: number
skipped: number
errors: Array<{ responseId: string; error: string }>
} | null>(null)
const testConnection = trpc.typeformImport.testConnection.useMutation()
const { data: schema, refetch: refetchSchema } =
trpc.typeformImport.getFormSchema.useQuery(
{ apiKey, formId },
{ enabled: false }
)
const { data: preview, refetch: refetchPreview } =
trpc.typeformImport.previewResponses.useQuery(
{ apiKey, formId, limit: 5 },
{ enabled: false }
)
const importMutation = trpc.typeformImport.importProjects.useMutation()
const handleConnect = async () => {
if (!apiKey || !formId) {
toast.error('Please enter both API key and form ID')
return
}
setIsConnecting(true)
setConnectionError(null)
try {
const result = await testConnection.mutateAsync({ apiKey })
if (!result.success) {
setConnectionError(result.error || 'Connection failed')
return
}
// Fetch schema
await refetchSchema()
setStep('map')
} catch (error) {
setConnectionError(
error instanceof Error ? error.message : 'Connection failed'
)
} finally {
setIsConnecting(false)
}
}
const handlePreview = async () => {
if (!mappings.title) {
toast.error('Please map the Title field')
return
}
await refetchPreview()
setStep('preview')
}
const handleImport = async () => {
setStep('import')
try {
const result = await importMutation.mutateAsync({
apiKey,
formId,
roundId,
mappings: {
title: mappings.title,
teamName: mappings.teamName || undefined,
description: mappings.description || undefined,
tags: mappings.tags || undefined,
email: mappings.email || undefined,
},
includeUnmappedInMetadata: includeUnmapped,
})
setImportResults(result)
setStep('complete')
if (result.imported > 0) {
toast.success(`Imported ${result.imported} projects`)
onSuccess?.()
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Import failed'
)
setStep('preview')
}
}
const fields = schema?.fields || []
return (
<div className="space-y-6">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
<div key={s} className="flex items-center">
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step === s
? 'bg-primary text-primary-foreground'
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
</div>
))}
</div>
{/* Step 1: Connect */}
{step === 'connect' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Connect to Typeform
</CardTitle>
<CardDescription>
Enter your Typeform API key and form ID to connect
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">Typeform API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="tfp_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Get your API key from{' '}
<a
href="https://admin.typeform.com/user/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Typeform Admin
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="formId">Form ID</Label>
<Input
id="formId"
placeholder="abc123..."
value={formId}
onChange={(e) => setFormId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
The ID from your Typeform URL (e.g., typeform.com/to/ABC123)
</p>
</div>
{connectionError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>{connectionError}</AlertDescription>
</Alert>
)}
<Button
onClick={handleConnect}
disabled={isConnecting || !apiKey || !formId}
>
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Connect
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Map columns */}
{step === 'map' && (
<Card>
<CardHeader>
<CardTitle>Map Questions</CardTitle>
<CardDescription>
Map Typeform questions to project fields. Form: {schema?.title}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
Title <span className="text-destructive">*</span>
</Label>
<Select
value={mappings.title}
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select question" />
</SelectTrigger>
<SelectContent>
{fields.map((f) => (
<SelectItem key={f.id} value={f.title}>
<div className="flex flex-col">
<span className="truncate max-w-[200px]">{f.title}</span>
<span className="text-xs text-muted-foreground">
{f.type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Team Name</Label>
<Select
value={mappings.teamName}
onValueChange={(v) =>
setMappings((m) => ({ ...m, teamName: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Select
value={mappings.description}
onValueChange={(v) =>
setMappings((m) => ({ ...m, description: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields
.filter((f) => f.type === 'long_text' || f.type === 'short_text')
.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<Select
value={mappings.tags}
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields
.filter(
(f) =>
f.type === 'multiple_choice' || f.type === 'dropdown'
)
.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Select
value={mappings.email}
onValueChange={(v) => setMappings((m) => ({ ...m, email: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select question (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{fields
.filter((f) => f.type === 'email')
.map((f) => (
<SelectItem key={f.id} value={f.title}>
{f.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="includeUnmapped"
checked={includeUnmapped}
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
/>
<Label htmlFor="includeUnmapped" className="font-normal">
Store unmapped answers in metadata
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('connect')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handlePreview} disabled={!mappings.title}>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<Card>
<CardHeader>
<CardTitle>Preview Import</CardTitle>
<CardDescription>
Review the first {preview?.count || 0} responses before importing
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium">Title</th>
<th className="px-4 py-2 text-left font-medium">Team</th>
<th className="px-4 py-2 text-left font-medium">Email</th>
</tr>
</thead>
<tbody>
{preview?.records.map((record, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-2 truncate max-w-[200px]">
{String(record[mappings.title] || '-')}
</td>
<td className="px-4 py-2">
{mappings.teamName
? String(record[mappings.teamName] || '-')
: '-'}
</td>
<td className="px-4 py-2">
{mappings.email
? String(record[mappings.email] || '-')
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Ready to import</AlertTitle>
<AlertDescription>
This will import all responses from the Typeform into{' '}
<strong>{roundName}</strong>.
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep('map')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleImport}>
Import All Responses
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Importing */}
{step === 'import' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Importing responses...</p>
<p className="text-sm text-muted-foreground">
Please wait while we import your data from Typeform
</p>
</CardContent>
</Card>
)}
{/* Step 5: Complete */}
{step === 'complete' && importResults && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-lg font-medium">Import Complete</p>
<div className="mt-4 text-center">
<p className="text-2xl font-bold text-green-600">
{importResults.imported}
</p>
<p className="text-sm text-muted-foreground">projects imported</p>
</div>
{importResults.skipped > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{importResults.skipped} responses skipped
</p>
)}
{importResults.errors.length > 0 && (
<div className="mt-4 w-full max-w-md">
<p className="text-sm font-medium text-destructive mb-2">
Errors ({importResults.errors.length}):
</p>
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
{importResults.errors.slice(0, 5).map((e, i) => (
<p key={i}>
{e.responseId}: {e.error}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
'use client'
import { EditionProvider, type Edition } from '@/contexts/edition-context'
import { Suspense, type ReactNode } from 'react'
export function AdminEditionWrapper({
children,
editions,
}: {
children: ReactNode
editions: Edition[]
}) {
return (
<Suspense fallback={null}>
<EditionProvider editions={editions}>
{children}
</EditionProvider>
</Suspense>
)
}

View File

@@ -0,0 +1,304 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import {
LayoutDashboard,
FolderKanban,
Users,
ClipboardList,
Settings,
FileSpreadsheet,
Menu,
X,
LogOut,
ChevronRight,
BookOpen,
Handshake,
FileText,
CircleDot,
History,
User,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
interface AdminSidebarProps {
user: {
name?: string | null
email?: string | null
role?: string
}
}
// Main navigation - scoped to selected edition
const navigation = [
{
name: 'Dashboard',
href: '/admin' as const,
icon: LayoutDashboard,
},
{
name: 'Rounds',
href: '/admin/rounds' as const,
icon: CircleDot,
},
{
name: 'Projects',
href: '/admin/projects' as const,
icon: ClipboardList,
},
{
name: 'Jury Members',
href: '/admin/users' as const,
icon: Users,
},
{
name: 'Reports',
href: '/admin/reports' as const,
icon: FileSpreadsheet,
},
{
name: 'Learning Hub',
href: '/admin/learning' as const,
icon: BookOpen,
},
{
name: 'Partners',
href: '/admin/partners' as const,
icon: Handshake,
},
{
name: 'Forms',
href: '/admin/forms' as const,
icon: FileText,
},
]
// Admin-only navigation
const adminNavigation = [
{
name: 'Manage Editions',
href: '/admin/programs' as const,
icon: FolderKanban,
},
{
name: 'Audit Log',
href: '/admin/audit' as const,
icon: History,
},
{
name: 'Settings',
href: '/admin/settings' as const,
icon: Settings,
},
]
// Role display labels
const roleLabels: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
return (
<>
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
<Logo showText textSuffix="Admin" />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
{/* Mobile menu overlay */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r bg-card transition-transform duration-300 lg:translate-x-0',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Logo */}
<div className="flex h-16 items-center border-b px-6">
<Logo showText textSuffix="Admin" />
</div>
{/* Edition Selector */}
<div className="border-b px-4 py-4">
<EditionSelector />
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
)
})}
</div>
{isSuperAdmin && (
<>
<Separator className="my-4" />
<div className="space-y-1">
<p className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Administration
</p>
{adminNavigation.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150',
isActive
? 'bg-brand-blue text-white shadow-xs'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className={cn(
'h-4 w-4 transition-colors',
isActive ? 'text-white' : 'text-muted-foreground group-hover:text-foreground'
)} />
{item.name}
</Link>
)
})}
</div>
</>
)}
</nav>
{/* User Profile Section */}
<div className="border-t p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{/* Avatar */}
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue text-white shadow-xs transition-transform duration-200 group-hover:scale-[1.02]">
<span className="text-sm font-semibold">
{getInitials(user.name || user.email || 'U')}
</span>
{/* Online indicator */}
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
</div>
{/* User info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{roleLabel}
</p>
</div>
{/* Chevron */}
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-slate-600" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={8}
className="w-56 p-1.5"
>
{/* User info header */}
<div className="px-2 py-2.5">
<p className="text-sm font-semibold text-foreground">
{user.name || 'User'}
</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
</div>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem asChild>
<Link
href="/admin/settings"
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<User className="h-4 w-4 text-muted-foreground" />
<span>Profile Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<LogOut className="h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
</>
)
}

View File

@@ -0,0 +1,176 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { BookOpen, ClipboardList, Home, LogOut, Menu, User, X } from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
interface JuryNavProps {
user: {
name?: string | null
email?: string | null
}
}
const navigation = [
{
name: 'Dashboard',
href: '/jury' as const,
icon: Home,
},
{
name: 'My Assignments',
href: '/jury/assignments' as const,
icon: ClipboardList,
},
{
name: 'Learning Hub',
href: '/jury/learning' as const,
icon: BookOpen,
},
]
export function JuryNav({ user }: JuryNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
return (
<>
{/* Desktop header */}
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Logo showText textSuffix="Jury" />
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/jury' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/jury' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
</>
)
}

View File

@@ -0,0 +1,177 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { BookOpen, Home, LogOut, Menu, User, Users, X } from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
interface MentorNavProps {
user: {
name?: string | null
email?: string | null
}
}
const navigation: { name: string; href: Route; icon: typeof Home }[] = [
{
name: 'Dashboard',
href: '/mentor' as Route,
icon: Home,
},
{
name: 'My Mentees',
href: '/mentor/projects' as Route,
icon: Users,
},
{
name: 'Resources',
href: '/mentor/resources' as Route,
icon: BookOpen,
},
]
export function MentorNav({ user }: MentorNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
return (
<>
{/* Desktop header */}
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Logo showText textSuffix="Mentor" />
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/mentor' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/mentor' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
</>
)
}

View File

@@ -0,0 +1,162 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Home, BarChart3, Menu, X, LogOut, Eye } from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
interface ObserverNavProps {
user: {
name?: string | null
email?: string | null
}
}
const navigation = [
{
name: 'Dashboard',
href: '/observer' as const,
icon: Home,
},
{
name: 'Reports',
href: '/observer/reports' as const,
icon: BarChart3,
},
]
export function ObserverNav({ user }: ObserverNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app flex h-16 items-center justify-between">
{/* Logo */}
<Logo showText textSuffix="Observer" />
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/observer' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User Menu */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email || 'O')}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline text-sm truncate max-w-[120px]">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-3 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/observer' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="pt-2 border-t">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
)
}

View File

@@ -0,0 +1,258 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Bot, Loader2, Zap } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const formSchema = z.object({
ai_enabled: z.boolean(),
ai_provider: z.string(),
ai_model: z.string(),
ai_send_descriptions: z.boolean(),
openai_api_key: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
interface AISettingsFormProps {
settings: {
ai_enabled?: string
ai_provider?: string
ai_model?: string
ai_send_descriptions?: string
openai_api_key?: string
}
}
export function AISettingsForm({ settings }: AISettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
ai_enabled: settings.ai_enabled === 'true',
ai_provider: settings.ai_provider || 'openai',
ai_model: settings.ai_model || 'gpt-4o',
ai_send_descriptions: settings.ai_send_descriptions === 'true',
openai_api_key: '',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('AI settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'AI' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const testConnection = trpc.settings.testAIConnection.useMutation({
onSuccess: (result) => {
if (result.success) {
toast.success('AI connection successful')
} else {
toast.error(`Connection failed: ${result.error}`)
}
},
onError: (error) => {
toast.error(`Test failed: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
const settingsToUpdate = [
{ key: 'ai_enabled', value: String(data.ai_enabled) },
{ key: 'ai_provider', value: data.ai_provider },
{ key: 'ai_model', value: data.ai_model },
{ key: 'ai_send_descriptions', value: String(data.ai_send_descriptions) },
]
// Only update API key if a new value was entered
if (data.openai_api_key && data.openai_api_key.trim()) {
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
}
updateSettings.mutate({ settings: settingsToUpdate })
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="ai_enabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable AI Features</FormLabel>
<FormDescription>
Use AI to suggest optimal jury-project assignments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_provider"
render={({ field }) => (
<FormItem>
<FormLabel>AI Provider</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
<FormDescription>
AI provider for smart assignment suggestions
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gpt-4o">GPT-4o (Recommended)</SelectItem>
<SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem>
<SelectItem value="gpt-4-turbo">GPT-4 Turbo</SelectItem>
<SelectItem value="gpt-4">GPT-4</SelectItem>
</SelectContent>
</Select>
<FormDescription>
OpenAI model to use for AI features
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
{...field}
/>
</FormControl>
<FormDescription>
Your OpenAI API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ai_send_descriptions"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Send Project Descriptions</FormLabel>
<FormDescription>
Include anonymized project descriptions in AI requests for better matching
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={updateSettings.isPending}
>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Bot className="mr-2 h-4 w-4" />
Save AI Settings
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testConnection.mutate()}
disabled={testConnection.isPending}
>
{testConnection.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,219 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Loader2, Palette } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
const hexColorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
const formSchema = z.object({
platform_name: z.string().min(1, 'Platform name is required'),
primary_color: z.string().regex(hexColorRegex, 'Invalid hex color'),
secondary_color: z.string().regex(hexColorRegex, 'Invalid hex color'),
accent_color: z.string().regex(hexColorRegex, 'Invalid hex color'),
})
type FormValues = z.infer<typeof formSchema>
interface BrandingSettingsFormProps {
settings: {
platform_name?: string
primary_color?: string
secondary_color?: string
accent_color?: string
}
}
export function BrandingSettingsForm({ settings }: BrandingSettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
platform_name: settings.platform_name || 'Monaco Ocean Protection Challenge',
primary_color: settings.primary_color || '#de0f1e',
secondary_color: settings.secondary_color || '#053d57',
accent_color: settings.accent_color || '#557f8c',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Branding settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'BRANDING' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'platform_name', value: data.platform_name },
{ key: 'primary_color', value: data.primary_color },
{ key: 'secondary_color', value: data.secondary_color },
{ key: 'accent_color', value: data.accent_color },
],
})
}
const watchedColors = form.watch(['primary_color', 'secondary_color', 'accent_color'])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="platform_name"
render={({ field }) => (
<FormItem>
<FormLabel>Platform Name</FormLabel>
<FormControl>
<Input placeholder="Monaco Ocean Protection Challenge" {...field} />
</FormControl>
<FormDescription>
The display name shown across the platform
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Color Preview */}
<div className="rounded-lg border p-4">
<p className="mb-3 text-sm font-medium">Color Preview</p>
<div className="flex gap-4">
<div className="flex flex-col items-center gap-2">
<div
className="h-12 w-12 rounded-lg border shadow-xs"
style={{ backgroundColor: watchedColors[0] }}
/>
<span className="text-xs text-muted-foreground">Primary</span>
</div>
<div className="flex flex-col items-center gap-2">
<div
className="h-12 w-12 rounded-lg border shadow-xs"
style={{ backgroundColor: watchedColors[1] }}
/>
<span className="text-xs text-muted-foreground">Secondary</span>
</div>
<div className="flex flex-col items-center gap-2">
<div
className="h-12 w-12 rounded-lg border shadow-xs"
style={{ backgroundColor: watchedColors[2] }}
/>
<span className="text-xs text-muted-foreground">Accent</span>
</div>
</div>
</div>
<FormField
control={form.control}
name="primary_color"
render={({ field }) => (
<FormItem>
<FormLabel>Primary Color</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="color" className="h-10 w-14 cursor-pointer p-1" {...field} />
</FormControl>
<FormControl>
<Input
placeholder="#de0f1e"
{...field}
className="flex-1"
/>
</FormControl>
</div>
<FormDescription>
Used for CTAs, alerts, and primary actions
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secondary_color"
render={({ field }) => (
<FormItem>
<FormLabel>Secondary Color</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="color" className="h-10 w-14 cursor-pointer p-1" {...field} />
</FormControl>
<FormControl>
<Input
placeholder="#053d57"
{...field}
className="flex-1"
/>
</FormControl>
</div>
<FormDescription>
Used for headers and sidebar
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accent_color"
render={({ field }) => (
<FormItem>
<FormLabel>Accent Color</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="color" className="h-10 w-14 cursor-pointer p-1" {...field} />
</FormControl>
<FormControl>
<Input
placeholder="#557f8c"
{...field}
className="flex-1"
/>
</FormControl>
</div>
<FormDescription>
Used for links and secondary elements
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
Save Branding Settings
</>
)}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Loader2, Settings } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const COMMON_TIMEZONES = [
{ value: 'Europe/Monaco', label: 'Monaco (CET/CEST)' },
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'America/New_York', label: 'New York (EST/EDT)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Singapore', label: 'Singapore (SGT)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST/AEDT)' },
{ value: 'UTC', label: 'UTC' },
]
const formSchema = z.object({
default_timezone: z.string().min(1, 'Timezone is required'),
default_page_size: z.string().regex(/^\d+$/, 'Must be a number'),
autosave_interval_seconds: z.string().regex(/^\d+$/, 'Must be a number'),
})
type FormValues = z.infer<typeof formSchema>
interface DefaultsSettingsFormProps {
settings: {
default_timezone?: string
default_page_size?: string
autosave_interval_seconds?: string
}
}
export function DefaultsSettingsForm({ settings }: DefaultsSettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
default_timezone: settings.default_timezone || 'Europe/Monaco',
default_page_size: settings.default_page_size || '20',
autosave_interval_seconds: settings.autosave_interval_seconds || '30',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Default settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'DEFAULTS' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'default_timezone', value: data.default_timezone },
{ key: 'default_page_size', value: data.default_page_size },
{ key: 'autosave_interval_seconds', value: data.autosave_interval_seconds },
],
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="default_timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Default Timezone</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
</FormControl>
<SelectContent>
{COMMON_TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Timezone used for displaying dates and deadlines across the platform
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="default_page_size"
render={({ field }) => (
<FormItem>
<FormLabel>Default Page Size</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select page size" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="10">10 items per page</SelectItem>
<SelectItem value="20">20 items per page</SelectItem>
<SelectItem value="50">50 items per page</SelectItem>
<SelectItem value="100">100 items per page</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Default number of items shown in lists and tables
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="autosave_interval_seconds"
render={({ field }) => (
<FormItem>
<FormLabel>Autosave Interval (seconds)</FormLabel>
<FormControl>
<Input type="number" min="10" max="120" placeholder="30" {...field} />
</FormControl>
<FormDescription>
How often evaluation forms are automatically saved while editing.
Lower values provide better data protection but increase server load.
Recommended: 30 seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Settings className="mr-2 h-4 w-4" />
Save Default Settings
</>
)}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,270 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Loader2, Mail, Send } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
const formSchema = z.object({
smtp_host: z.string().min(1, 'SMTP host is required'),
smtp_port: z.string().regex(/^\d+$/, 'Port must be a number'),
smtp_user: z.string().min(1, 'SMTP user is required'),
smtp_password: z.string().optional(),
email_from: z.string().email('Invalid email address'),
})
type FormValues = z.infer<typeof formSchema>
interface EmailSettingsFormProps {
settings: {
smtp_host?: string
smtp_port?: string
smtp_user?: string
smtp_password?: string
email_from?: string
}
}
export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
const [testDialogOpen, setTestDialogOpen] = useState(false)
const [testEmail, setTestEmail] = useState('')
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
smtp_host: settings.smtp_host || 'localhost',
smtp_port: settings.smtp_port || '587',
smtp_user: settings.smtp_user || '',
smtp_password: '',
email_from: settings.email_from || 'noreply@monaco-opc.com',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Email settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'EMAIL' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const sendTestEmail = trpc.settings.testEmailConnection.useMutation({
onSuccess: (result) => {
setTestDialogOpen(false)
if (result.success) {
toast.success('Test email sent successfully')
} else {
toast.error(`Failed to send test email: ${result.error}`)
}
},
onError: (error) => {
toast.error(`Test failed: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
const settingsToUpdate = [
{ key: 'smtp_host', value: data.smtp_host },
{ key: 'smtp_port', value: data.smtp_port },
{ key: 'smtp_user', value: data.smtp_user },
{ key: 'email_from', value: data.email_from },
]
if (data.smtp_password && data.smtp_password.trim()) {
settingsToUpdate.push({ key: 'smtp_password', value: data.smtp_password })
}
updateSettings.mutate({ settings: settingsToUpdate })
}
const handleSendTest = () => {
if (!testEmail) {
toast.error('Please enter an email address')
return
}
sendTestEmail.mutate({ testEmail })
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800">
Email settings are typically configured via environment variables. Changes here
will be stored in the database but may be overridden by environment variables.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="smtp_host"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input placeholder="smtp.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtp_port"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input placeholder="587" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="smtp_user"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP User</FormLabel>
<FormControl>
<Input placeholder="user@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtp_password"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.smtp_password ? '••••••••' : 'Enter password'}
{...field}
/>
</FormControl>
<FormDescription>
Leave blank to keep existing password
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email_from"
render={({ field }) => (
<FormItem>
<FormLabel>From Email Address</FormLabel>
<FormControl>
<Input placeholder="noreply@monaco-opc.com" {...field} />
</FormControl>
<FormDescription>
Email address that will appear as the sender
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Save Email Settings
</>
)}
</Button>
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Send className="mr-2 h-4 w-4" />
Send Test Email
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
<DialogDescription>
Enter an email address to receive a test email
</DialogDescription>
</DialogHeader>
<Input
type="email"
placeholder="test@example.com"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setTestDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleSendTest}
disabled={sendTestEmail.isPending}
>
{sendTestEmail.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
'Send Test'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Loader2, Shield } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
const formSchema = z.object({
session_duration_hours: z.string().regex(/^\d+$/, 'Must be a number'),
magic_link_expiry_minutes: z.string().regex(/^\d+$/, 'Must be a number'),
rate_limit_requests_per_minute: z.string().regex(/^\d+$/, 'Must be a number'),
})
type FormValues = z.infer<typeof formSchema>
interface SecuritySettingsFormProps {
settings: {
session_duration_hours?: string
magic_link_expiry_minutes?: string
rate_limit_requests_per_minute?: string
}
}
export function SecuritySettingsForm({ settings }: SecuritySettingsFormProps) {
const utils = trpc.useUtils()
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
session_duration_hours: settings.session_duration_hours || '24',
magic_link_expiry_minutes: settings.magic_link_expiry_minutes || '15',
rate_limit_requests_per_minute: settings.rate_limit_requests_per_minute || '60',
},
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Security settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'SECURITY' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'session_duration_hours', value: data.session_duration_hours },
{ key: 'magic_link_expiry_minutes', value: data.magic_link_expiry_minutes },
{ key: 'rate_limit_requests_per_minute', value: data.rate_limit_requests_per_minute },
],
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="session_duration_hours"
render={({ field }) => (
<FormItem>
<FormLabel>Session Duration (hours)</FormLabel>
<FormControl>
<Input type="number" min="1" max="720" placeholder="24" {...field} />
</FormControl>
<FormDescription>
How long user sessions remain valid before requiring re-authentication.
Recommended: 24 hours for jury members, up to 168 hours (1 week) for admins.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="magic_link_expiry_minutes"
render={({ field }) => (
<FormItem>
<FormLabel>Magic Link Expiry (minutes)</FormLabel>
<FormControl>
<Input type="number" min="5" max="60" placeholder="15" {...field} />
</FormControl>
<FormDescription>
How long magic link authentication links remain valid.
Shorter is more secure. Recommended: 15 minutes.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="rate_limit_requests_per_minute"
render={({ field }) => (
<FormItem>
<FormLabel>API Rate Limit (requests/minute)</FormLabel>
<FormControl>
<Input type="number" min="10" max="300" placeholder="60" {...field} />
</FormControl>
<FormDescription>
Maximum API requests allowed per minute per user.
Helps prevent abuse and ensures fair resource usage.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800">
<strong>Security Note:</strong> Changing these settings affects all users immediately.
Reducing session duration will not log out existing sessions but will prevent renewal.
</p>
</div>
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Shield className="mr-2 h-4 w-4" />
Save Security Settings
</>
)}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import {
Bot,
Palette,
Mail,
HardDrive,
Shield,
Settings as SettingsIcon,
} from 'lucide-react'
import { AISettingsForm } from './ai-settings-form'
import { BrandingSettingsForm } from './branding-settings-form'
import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form'
import { SecuritySettingsForm } from './security-settings-form'
import { DefaultsSettingsForm } from './defaults-settings-form'
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>
)
}
interface SettingsContentProps {
initialSettings: Record<string, string>
}
export function SettingsContent({ initialSettings }: SettingsContentProps) {
// We use the initial settings passed from the server
// Forms will refetch on mutation success
// Helper to get settings by prefix
const getSettingsByKeys = (keys: string[]) => {
const result: Record<string, string> = {}
keys.forEach((key) => {
if (initialSettings[key] !== undefined) {
result[key] = initialSettings[key]
}
})
return result
}
const aiSettings = getSettingsByKeys([
'ai_enabled',
'ai_provider',
'ai_model',
'ai_send_descriptions',
'openai_api_key',
])
const brandingSettings = getSettingsByKeys([
'platform_name',
'primary_color',
'secondary_color',
'accent_color',
])
const emailSettings = getSettingsByKeys([
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'email_from',
])
const storageSettings = getSettingsByKeys([
'max_file_size_mb',
'allowed_file_types',
])
const securitySettings = getSettingsByKeys([
'session_duration_hours',
'magic_link_expiry_minutes',
'rate_limit_requests_per_minute',
])
const defaultsSettings = getSettingsByKeys([
'default_timezone',
'default_page_size',
'autosave_interval_seconds',
])
return (
<Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
<TabsTrigger value="ai" className="gap-2">
<Bot className="h-4 w-4" />
<span className="hidden sm:inline">AI</span>
</TabsTrigger>
<TabsTrigger value="branding" className="gap-2">
<Palette className="h-4 w-4" />
<span className="hidden sm:inline">Branding</span>
</TabsTrigger>
<TabsTrigger value="email" className="gap-2">
<Mail className="h-4 w-4" />
<span className="hidden sm:inline">Email</span>
</TabsTrigger>
<TabsTrigger value="storage" className="gap-2">
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Storage</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Security</span>
</TabsTrigger>
<TabsTrigger value="defaults" className="gap-2">
<SettingsIcon className="h-4 w-4" />
<span className="hidden sm:inline">Defaults</span>
</TabsTrigger>
</TabsList>
<TabsContent value="ai">
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>
Configure AI-powered features like smart jury assignment
</CardDescription>
</CardHeader>
<CardContent>
<AISettingsForm settings={aiSettings} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="branding">
<Card>
<CardHeader>
<CardTitle>Platform Branding</CardTitle>
<CardDescription>
Customize the look and feel of your platform
</CardDescription>
</CardHeader>
<CardContent>
<BrandingSettingsForm settings={brandingSettings} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="email">
<Card>
<CardHeader>
<CardTitle>Email Configuration</CardTitle>
<CardDescription>
Configure email settings for notifications and magic links
</CardDescription>
</CardHeader>
<CardContent>
<EmailSettingsForm settings={emailSettings} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="storage">
<Card>
<CardHeader>
<CardTitle>File Storage</CardTitle>
<CardDescription>
Configure file upload limits and allowed types
</CardDescription>
</CardHeader>
<CardContent>
<StorageSettingsForm settings={storageSettings} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>
Configure security and access control settings
</CardDescription>
</CardHeader>
<CardContent>
<SecuritySettingsForm settings={securitySettings} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="defaults">
<Card>
<CardHeader>
<CardTitle>Default Settings</CardTitle>
<CardDescription>
Configure default values for the platform
</CardDescription>
</CardHeader>
<CardContent>
<DefaultsSettingsForm settings={defaultsSettings} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}
export { SettingsSkeleton }

View File

@@ -0,0 +1,293 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { HardDrive, Loader2, Cloud, FolderOpen } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
// Note: Storage provider cache is cleared server-side when settings are updated
const COMMON_FILE_TYPES = [
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
{ value: 'video/quicktime', label: 'QuickTime Video (.mov)' },
{ value: 'image/png', label: 'PNG Images (.png)' },
{ value: 'image/jpeg', label: 'JPEG Images (.jpg, .jpeg)' },
{ value: 'image/gif', label: 'GIF Images (.gif)' },
{ value: 'image/webp', label: 'WebP Images (.webp)' },
{ value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', label: 'Word Documents (.docx)' },
{ value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', label: 'Excel Spreadsheets (.xlsx)' },
{ value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', label: 'PowerPoint (.pptx)' },
]
const formSchema = z.object({
storage_provider: z.enum(['s3', 'local']),
local_storage_path: z.string().optional(),
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
})
type FormValues = z.infer<typeof formSchema>
interface StorageSettingsFormProps {
settings: {
storage_provider?: string
local_storage_path?: string
max_file_size_mb?: string
avatar_max_size_mb?: string
allowed_file_types?: string
}
}
export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
const utils = trpc.useUtils()
// Parse allowed file types from JSON string
let allowedTypes: string[] = []
try {
allowedTypes = settings.allowed_file_types
? JSON.parse(settings.allowed_file_types)
: ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
} catch {
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
}
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
storage_provider: (settings.storage_provider as 's3' | 'local') || 's3',
local_storage_path: settings.local_storage_path || './uploads',
max_file_size_mb: settings.max_file_size_mb || '500',
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
allowed_file_types: allowedTypes,
},
})
const storageProvider = form.watch('storage_provider')
const updateSettings = trpc.settings.updateMultiple.useMutation({
onSuccess: () => {
toast.success('Storage settings saved successfully')
utils.settings.getByCategory.invalidate({ category: 'STORAGE' })
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const onSubmit = (data: FormValues) => {
updateSettings.mutate({
settings: [
{ key: 'storage_provider', value: data.storage_provider },
{ key: 'local_storage_path', value: data.local_storage_path || './uploads' },
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
],
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Storage Provider Selection */}
<FormField
control={form.control}
name="storage_provider"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Storage Provider</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid gap-4 md:grid-cols-2"
>
<div className="flex items-start space-x-3 rounded-lg border p-4">
<RadioGroupItem value="s3" id="s3" className="mt-1" />
<Label htmlFor="s3" className="flex flex-col cursor-pointer">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4" />
<span className="font-medium">S3 / MinIO</span>
</div>
<span className="text-sm text-muted-foreground">
Store files in MinIO or S3-compatible storage. Recommended for production.
</span>
</Label>
</div>
<div className="flex items-start space-x-3 rounded-lg border p-4">
<RadioGroupItem value="local" id="local" className="mt-1" />
<Label htmlFor="local" className="flex flex-col cursor-pointer">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
<span className="font-medium">Local Filesystem</span>
</div>
<span className="text-sm text-muted-foreground">
Store files on the local server. Good for development or single-server deployments.
</span>
</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Local Storage Path (only shown when local is selected) */}
{storageProvider === 'local' && (
<FormField
control={form.control}
name="local_storage_path"
render={({ field }) => (
<FormItem>
<FormLabel>Local Storage Path</FormLabel>
<FormControl>
<Input placeholder="./uploads" {...field} />
</FormControl>
<FormDescription>
Directory path where files will be stored. Relative paths are from the app root.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="max_file_size_mb"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum File Size (MB)</FormLabel>
<FormControl>
<Input type="number" min="1" max="2000" placeholder="500" {...field} />
</FormControl>
<FormDescription>
Maximum allowed file upload size in megabytes. Recommended: 500 MB for video uploads.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatar_max_size_mb"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum Avatar/Logo Size (MB)</FormLabel>
<FormControl>
<Input type="number" min="1" max="50" placeholder="5" {...field} />
</FormControl>
<FormDescription>
Maximum size for profile pictures and project logos.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowed_file_types"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Allowed File Types</FormLabel>
<FormDescription>
Select which file types can be uploaded to the platform
</FormDescription>
</div>
<div className="grid gap-3 md:grid-cols-2">
{COMMON_FILE_TYPES.map((type) => (
<FormField
key={type.value}
control={form.control}
name="allowed_file_types"
render={({ field }) => {
return (
<FormItem
key={type.value}
className="flex items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(type.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, type.value])
: field.onChange(
field.value?.filter(
(value) => value !== type.value
)
)
}}
/>
</FormControl>
<FormLabel className="cursor-pointer text-sm font-normal">
{type.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{storageProvider === 's3' && (
<div className="rounded-lg border border-muted bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> MinIO connection settings (endpoint, bucket, credentials) are
configured via environment variables for security. Set <code>MINIO_ENDPOINT</code> and{' '}
<code>MINIO_PUBLIC_ENDPOINT</code> for external MinIO servers.
</p>
</div>
)}
{storageProvider === 'local' && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Warning:</strong> Local storage is not recommended for production deployments
with multiple servers, as files will only be accessible from the server that uploaded them.
</p>
</div>
)}
<Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<HardDrive className="mr-2 h-4 w-4" />
Save Storage Settings
</>
)}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { UserAvatar } from './user-avatar'
import { Upload, Loader2, Trash2 } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type AvatarUploadProps = {
user: {
name?: string | null
email?: string | null
profileImageKey?: string | null
}
currentAvatarUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export function AvatarUpload({
user,
currentAvatarUrl,
onUploadComplete,
children,
}: AvatarUploadProps) {
const [open, setOpen] = useState(false)
const [preview, setPreview] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const getUploadUrl = trpc.avatar.getUploadUrl.useMutation()
const confirmUpload = trpc.avatar.confirmUpload.useMutation()
const deleteAvatar = trpc.avatar.delete.useMutation()
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
// Validate size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
setSelectedFile(file)
// Create preview
const reader = new FileReader()
reader.onload = (e) => {
setPreview(e.target?.result as string)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!selectedFile) return
setIsUploading(true)
try {
// Get pre-signed upload URL (includes provider type for tracking)
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
fileName: selectedFile.name,
contentType: selectedFile.type,
})
// Upload file directly to storage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: selectedFile,
headers: {
'Content-Type': selectedFile.type,
},
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
// Confirm upload with the provider type that was used
await confirmUpload.mutateAsync({ key, providerType })
// Invalidate avatar query
utils.avatar.getUrl.invalidate()
toast.success('Avatar updated successfully')
setOpen(false)
setPreview(null)
setSelectedFile(null)
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload avatar. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteAvatar.mutateAsync()
utils.avatar.getUrl.invalidate()
toast.success('Avatar removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove avatar')
} finally {
setIsDeleting(false)
}
}
const handleCancel = () => {
setPreview(null)
setSelectedFile(null)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<div className="cursor-pointer">
<UserAvatar user={user} avatarUrl={currentAvatarUrl} showEditOverlay />
</div>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Profile Picture</DialogTitle>
<DialogDescription>
Upload a new profile picture. Allowed formats: JPEG, PNG, GIF, WebP.
Max size: {MAX_SIZE_MB}MB.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Preview */}
<div className="flex justify-center">
<UserAvatar
user={user}
avatarUrl={preview || currentAvatarUrl}
size="xl"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="avatar">Select image</Label>
<Input
ref={fileInputRef}
id="avatar"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentAvatarUrl && !preview && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useCreateBlockNote } from '@blocknote/react'
import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/core/fonts/inter.css'
import '@blocknote/mantine/style.css'
import type { PartialBlock } from '@blocknote/core'
interface BlockEditorProps {
initialContent?: string | null
onChange?: (content: string) => void
onUploadFile?: (file: File) => Promise<string>
editable?: boolean
className?: string
}
export function BlockEditor({
initialContent,
onChange,
onUploadFile,
editable = true,
className,
}: BlockEditorProps) {
const [mounted, setMounted] = useState(false)
// Parse initial content
const parsedContent = useMemo(() => {
if (!initialContent) return undefined
try {
return JSON.parse(initialContent) as PartialBlock[]
} catch {
return undefined
}
}, [initialContent])
// Default upload handler that uses the provided callback or creates blob URLs
const uploadFile = async (file: File): Promise<string> => {
if (onUploadFile) {
return onUploadFile(file)
}
// Fallback: create blob URL (not persistent)
return URL.createObjectURL(file)
}
const editor = useCreateBlockNote({
initialContent: parsedContent,
uploadFile,
})
// Handle content changes
useEffect(() => {
if (!onChange || !editor) return
const handleChange = () => {
const content = JSON.stringify(editor.document)
onChange(content)
}
// Subscribe to changes
editor.onEditorContentChange(handleChange)
}, [editor, onChange])
// Client-side only rendering
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<div className={`min-h-[200px] rounded-lg border bg-muted/20 animate-pulse ${className}`} />
)
}
return (
<div className={`bn-container ${className}`}>
<BlockNoteView
editor={editor}
editable={editable}
theme="light"
/>
</div>
)
}
// Read-only viewer component
interface BlockViewerProps {
content?: string | null
className?: string
}
export function BlockViewer({ content, className }: BlockViewerProps) {
const [mounted, setMounted] = useState(false)
const parsedContent = useMemo(() => {
if (!content) return undefined
try {
return JSON.parse(content) as PartialBlock[]
} catch {
return undefined
}
}, [content])
const editor = useCreateBlockNote({
initialContent: parsedContent,
})
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<div className={`min-h-[100px] rounded-lg border bg-muted/20 animate-pulse ${className}`} />
)
}
if (!content) {
return (
<div className={`text-muted-foreground text-sm ${className}`}>
No content available
</div>
)
}
return (
<div className={`bn-container ${className}`}>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
/>
</div>
)
}

View File

@@ -0,0 +1,178 @@
'use client'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useEdition } from '@/contexts/edition-context'
import { useState } from 'react'
const statusConfig: Record<string, { bg: string; text: string; dot: string }> = {
DRAFT: {
bg: 'bg-amber-50 dark:bg-amber-950/50',
text: 'text-amber-700 dark:text-amber-400',
dot: 'bg-amber-500',
},
ACTIVE: {
bg: 'bg-emerald-50 dark:bg-emerald-950/50',
text: 'text-emerald-700 dark:text-emerald-400',
dot: 'bg-emerald-500',
},
ARCHIVED: {
bg: 'bg-slate-100 dark:bg-slate-800/50',
text: 'text-slate-600 dark:text-slate-400',
dot: 'bg-slate-400',
},
}
export function EditionSelector() {
const { currentEdition, editions, setCurrentEdition, isLoading } = useEdition()
const [open, setOpen] = useState(false)
if (isLoading) {
return (
<div className="flex items-center gap-3 px-1 py-2">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-brand-blue/10 to-brand-teal/10 animate-pulse">
<span className="text-lg font-bold text-muted-foreground/50">--</span>
</div>
<div className="space-y-1.5">
<div className="h-3 w-16 rounded bg-muted animate-pulse" />
<div className="h-2.5 w-10 rounded bg-muted animate-pulse" />
</div>
</div>
)
}
if (editions.length === 0) {
return (
<div className="flex items-center gap-3 px-1 py-2">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-muted/50 border border-dashed border-muted-foreground/20">
<span className="text-lg font-bold text-muted-foreground/40">?</span>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">No editions</p>
<p className="text-xs text-muted-foreground/60">Create one to start</p>
</div>
</div>
)
}
const status = currentEdition ? statusConfig[currentEdition.status] : statusConfig.DRAFT
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
role="combobox"
aria-expanded={open}
className={cn(
'group flex w-full items-center gap-3 rounded-xl px-1 py-2 text-left transition-all duration-200',
'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
open && 'bg-muted/50'
)}
>
{/* Year Badge */}
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-brand-blue shadow-xs transition-transform duration-200 group-hover:scale-[1.02]">
<span className="text-lg font-bold tracking-tight text-white">
{currentEdition ? String(currentEdition.year).slice(-2) : '--'}
</span>
{/* Status indicator dot */}
<div className={cn(
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white',
status.dot
)} />
</div>
{/* Text */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{currentEdition ? currentEdition.year : 'Select'}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{currentEdition?.status === 'ACTIVE' ? 'Current Edition' : currentEdition?.status?.toLowerCase()}
</p>
</div>
{/* Chevron */}
<ChevronDown className={cn(
'h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform duration-200',
open && 'rotate-180'
)} />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[240px] p-0 shadow-lg border-border/50"
align="start"
sideOffset={8}
>
<Command className="rounded-lg">
<CommandList className="max-h-[280px]">
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
No editions found
</CommandEmpty>
<CommandGroup className="p-1.5">
{editions.map((edition) => {
const editionStatus = statusConfig[edition.status] || statusConfig.DRAFT
const isSelected = currentEdition?.id === edition.id
return (
<CommandItem
key={edition.id}
value={`${edition.name} ${edition.year}`}
onSelect={() => {
setCurrentEdition(edition.id)
setOpen(false)
}}
className={cn(
'group/item flex items-center gap-3 rounded-lg px-2.5 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'hover:bg-slate-50 dark:hover:bg-slate-800/50'
)}
>
{/* Year badge in dropdown */}
<div className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg font-bold text-sm transition-colors',
isSelected
? 'bg-brand-blue text-white'
: 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
)}>
{String(edition.year).slice(-2)}
</div>
{/* Edition info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{edition.year}
</p>
<div className="flex items-center gap-1.5">
<div className={cn('h-1.5 w-1.5 rounded-full', editionStatus.dot)} />
<span className="text-xs text-slate-500 dark:text-slate-400 capitalize">
{edition.status.toLowerCase()}
</span>
</div>
</div>
{/* Check mark */}
{isSelected && (
<Check className="h-4 w-4 shrink-0 text-brand-blue" />
)}
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,49 @@
import { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: {
label: string
href?: string
onClick?: () => void
}
className?: string
}
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className
)}
>
<Icon className="h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 font-medium">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
)}
{action && (
<Button
className="mt-4"
onClick={action.onClick}
asChild={!!action.href}
>
{action.href ? <a href={action.href}>{action.label}</a> : action.label}
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,462 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Upload,
X,
FileIcon,
CheckCircle2,
AlertCircle,
Loader2,
Film,
FileText,
Presentation,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER'
interface UploadingFile {
id: string
file: File
progress: number
status: 'pending' | 'uploading' | 'complete' | 'error'
error?: string
fileType: FileType
dbFileId?: string
}
interface FileUploadProps {
projectId: string
onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void
onUploadError?: (error: Error) => void
allowedTypes?: string[]
multiple?: boolean
className?: string
}
// Map MIME types to suggested file types
function suggestFileType(mimeType: string): FileType {
if (mimeType.startsWith('video/')) return 'VIDEO'
if (mimeType === 'application/pdf') return 'EXEC_SUMMARY'
if (
mimeType.includes('presentation') ||
mimeType.includes('powerpoint') ||
mimeType.includes('slides')
) {
return 'PRESENTATION'
}
return 'OTHER'
}
// Get icon for file type
function getFileTypeIcon(fileType: FileType) {
switch (fileType) {
case 'VIDEO':
return <Film className="h-4 w-4" />
case 'EXEC_SUMMARY':
return <FileText className="h-4 w-4" />
case 'PRESENTATION':
return <Presentation className="h-4 w-4" />
default:
return <FileIcon className="h-4 w-4" />
}
}
export function FileUpload({
projectId,
onUploadComplete,
onUploadError,
allowedTypes,
multiple = true,
className,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
const confirmUpload = trpc.file.confirmUpload.useMutation()
const utils = trpc.useUtils()
// Validate file
const validateFile = useCallback(
(file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`
}
if (allowedTypes && !allowedTypes.includes(file.type)) {
return `File type ${file.type} is not allowed`
}
return null
},
[allowedTypes]
)
// Upload a single file
const uploadFile = useCallback(
async (uploadingFile: UploadingFile) => {
const { file, id, fileType } = uploadingFile
try {
// Update status to uploading
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f))
)
// Get pre-signed upload URL
const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({
projectId,
fileName: file.name,
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
})
// Store the DB file ID
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f))
)
// Upload to MinIO using XHR for progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100)
setUploadingFiles((prev) =>
prev.map((f) => (f.id === id ? { ...f, progress } : f))
)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'))
})
xhr.open('PUT', uploadUrl)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
xhr.send(file)
})
// Confirm upload
await confirmUpload.mutateAsync({ fileId: dbFile.id })
// Update status to complete
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f
)
)
// Invalidate file list queries
utils.file.listByProject.invalidate({ projectId })
// Notify parent
onUploadComplete?.({
id: dbFile.id,
fileName: file.name,
fileType,
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Upload failed'
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f
)
)
onUploadError?.(error instanceof Error ? error : new Error(errorMessage))
}
},
[projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError]
)
// Handle file selection
const handleFiles = useCallback(
(files: FileList | File[]) => {
const fileArray = Array.from(files)
const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean)
const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => {
const validationError = validateFile(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
progress: 0,
status: validationError ? ('error' as const) : ('pending' as const),
error: validationError || undefined,
fileType: suggestFileType(file.type),
}
})
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
// Start uploading valid files
newUploadingFiles
.filter((f) => f.status === 'pending')
.forEach((f) => uploadFile(f))
},
[multiple, validateFile, uploadFile]
)
// Drag and drop handlers
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files?.length) {
handleFiles(e.dataTransfer.files)
}
},
[handleFiles]
)
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
handleFiles(e.target.files)
}
// Reset input so same file can be selected again
e.target.value = ''
},
[handleFiles]
)
// Update file type for a pending file
const updateFileType = useCallback((fileId: string, fileType: FileType) => {
setUploadingFiles((prev) =>
prev.map((f) => (f.id === fileId ? { ...f, fileType } : f))
)
}, [])
// Remove a file from the queue
const removeFile = useCallback((fileId: string) => {
setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId))
}, [])
// Retry a failed upload
const retryUpload = useCallback(
(fileId: string) => {
setUploadingFiles((prev) =>
prev.map((f) =>
f.id === fileId
? { ...f, status: 'pending' as const, progress: 0, error: undefined }
: f
)
)
const file = uploadingFiles.find((f) => f.id === fileId)
if (file) {
uploadFile({ ...file, status: 'pending', progress: 0, error: undefined })
}
},
[uploadingFiles, uploadFile]
)
const hasActiveUploads = uploadingFiles.some(
(f) => f.status === 'pending' || f.status === 'uploading'
)
return (
<div className={cn('space-y-4', className)}>
{/* Drop zone */}
<div
className={cn(
'relative border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
hasActiveUploads && 'opacity-50 pointer-events-none'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={allowedTypes?.join(',')}
onChange={handleFileInputChange}
className="hidden"
/>
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
</p>
<p className="text-sm text-muted-foreground">
or click to browse (max {formatFileSize(MAX_FILE_SIZE)})
</p>
</div>
{/* Upload queue */}
{uploadingFiles.length > 0 && (
<div className="space-y-2">
{uploadingFiles.map((uploadingFile) => (
<div
key={uploadingFile.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadingFile.status === 'error' && 'border-destructive/50 bg-destructive/5',
uploadingFile.status === 'complete' && 'border-green-500/50 bg-green-500/5'
)}
>
{/* File type icon */}
<div className="shrink-0 text-muted-foreground">
{getFileTypeIcon(uploadingFile.fileType)}
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{uploadingFile.file.name}
</p>
<Badge variant="outline" className="shrink-0 text-xs">
{formatFileSize(uploadingFile.file.size)}
</Badge>
</div>
{/* Progress bar or error message */}
{uploadingFile.status === 'uploading' && (
<div className="mt-1 flex items-center gap-2">
<Progress value={uploadingFile.progress} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground">
{uploadingFile.progress}%
</span>
</div>
)}
{uploadingFile.status === 'error' && (
<p className="mt-1 text-xs text-destructive">{uploadingFile.error}</p>
)}
{uploadingFile.status === 'pending' && (
<div className="mt-1 flex items-center gap-2">
<Select
value={uploadingFile.fileType}
onValueChange={(v) => updateFileType(uploadingFile.id, v as FileType)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXEC_SUMMARY">Executive Summary</SelectItem>
<SelectItem value="PRESENTATION">Presentation</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Status / Actions */}
<div className="shrink-0">
{uploadingFile.status === 'pending' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{uploadingFile.status === 'uploading' && (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
)}
{uploadingFile.status === 'complete' && (
<CheckCircle2 className="h-4 w-4 text-green-600" />
)}
{uploadingFile.status === 'error' && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
retryUpload(uploadingFile.id)
}}
>
Retry
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Remove file"
onClick={(e) => {
e.stopPropagation()
removeFile(uploadingFile.id)
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
))}
{/* Clear completed */}
{uploadingFiles.some((f) => f.status === 'complete') && (
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() =>
setUploadingFiles((prev) => prev.filter((f) => f.status !== 'complete'))
}
>
Clear completed
</Button>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,344 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
File,
Download,
ExternalLink,
Play,
FileImage,
Loader2,
AlertCircle,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface ProjectFile {
id: string
fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC'
fileName: string
mimeType: string
size: number
bucket: string
objectKey: string
}
interface FileViewerProps {
files: ProjectFile[]
className?: string
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
function getFileIcon(fileType: string, mimeType: string) {
if (mimeType.startsWith('video/')) return Video
if (mimeType.startsWith('image/')) return FileImage
if (mimeType === 'application/pdf') return FileText
if (fileType === 'EXEC_SUMMARY' || fileType === 'PRESENTATION') return FileText
if (fileType === 'VIDEO') return Video
return File
}
function getFileTypeLabel(fileType: string) {
switch (fileType) {
case 'EXEC_SUMMARY':
return 'Executive Summary'
case 'PRESENTATION':
return 'Presentation'
case 'VIDEO':
return 'Video'
case 'BUSINESS_PLAN':
return 'Business Plan'
case 'VIDEO_PITCH':
return 'Video Pitch'
case 'SUPPORTING_DOC':
return 'Supporting Document'
default:
return 'Attachment'
}
}
export function FileViewer({ files, className }: FileViewerProps) {
if (files.length === 0) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<File className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files attached</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Sort files by type order
const sortOrder = ['EXEC_SUMMARY', 'BUSINESS_PLAN', 'PRESENTATION', 'VIDEO', 'VIDEO_PITCH', 'SUPPORTING_DOC', 'OTHER']
const sortedFiles = [...files].sort(
(a, b) => sortOrder.indexOf(a.fileType) - sortOrder.indexOf(b.fileType)
)
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg">Project Files</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{sortedFiles.map((file) => (
<FileItem key={file.id} file={file} />
))}
</CardContent>
</Card>
)
}
function FileItem({ file }: { file: ProjectFile }) {
const [showPreview, setShowPreview] = useState(false)
const Icon = getFileIcon(file.fileType, file.mimeType)
const { data: urlData, isLoading: isLoadingUrl } = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket, objectKey: file.objectKey },
{ enabled: showPreview }
)
const canPreview = file.mimeType.startsWith('video/') || file.mimeType === 'application/pdf'
return (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{file.fileName}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary" className="text-xs">
{getFileTypeLabel(file.fileType)}
</Badge>
<span>{formatFileSize(file.size)}</span>
</div>
</div>
<div className="flex items-center gap-2">
{canPreview && (
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<>
<X className="mr-2 h-4 w-4" />
Close
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Preview
</>
)}
</Button>
)}
<FileDownloadButton file={file} />
</div>
</div>
{/* Preview area */}
{showPreview && (
<div className="rounded-lg border bg-muted/50 overflow-hidden">
{isLoadingUrl ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : urlData?.url ? (
<FilePreview file={file} url={urlData.url} />
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
Failed to load preview
</div>
)}
</div>
)}
</div>
)
}
function FileDownloadButton({ file }: { file: ProjectFile }) {
const [downloading, setDownloading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket, objectKey: file.objectKey },
{ enabled: false }
)
const handleDownload = async () => {
setDownloading(true)
try {
const result = await refetch()
if (result.data?.url) {
// Open in new tab for download
window.open(result.data.url, '_blank')
}
} catch (error) {
console.error('Failed to get download URL:', error)
} finally {
setDownloading(false)
}
}
return (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
aria-label="Download file"
>
{downloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)
}
function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
if (file.mimeType.startsWith('video/')) {
return (
<video
src={url}
controls
className="w-full max-h-[500px]"
preload="metadata"
>
Your browser does not support the video tag.
</video>
)
}
if (file.mimeType === 'application/pdf') {
return (
<div className="relative">
<iframe
src={`${url}#toolbar=0`}
className="w-full h-[600px]"
title={file.fileName}
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
asChild
>
<a href={url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open in new tab
</a>
</Button>
</div>
)
}
return (
<div className="flex items-center justify-center py-8 text-muted-foreground">
Preview not available for this file type
</div>
)
}
// Compact file list for smaller views
export function FileList({ files, className }: FileViewerProps) {
if (files.length === 0) return null
return (
<div className={cn('space-y-2', className)}>
{files.map((file) => {
const Icon = getFileIcon(file.fileType, file.mimeType)
return (
<CompactFileItem key={file.id} file={file} />
)
})}
</div>
)
}
function CompactFileItem({ file }: { file: ProjectFile }) {
const [loading, setLoading] = useState(false)
const Icon = getFileIcon(file.fileType, file.mimeType)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket, objectKey: file.objectKey },
{ enabled: false }
)
const handleClick = async () => {
setLoading(true)
try {
const result = await refetch()
if (result.data?.url) {
window.open(result.data.url, '_blank')
}
} catch (error) {
console.error('Failed to get download URL:', error)
} finally {
setLoading(false)
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="flex w-full items-center gap-2 rounded-md border p-2 text-left hover:bg-muted transition-colors disabled:opacity-50"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
) : (
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate text-sm">{file.fileName}</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(file.size)}
</span>
</button>
)
}
export function FileViewerSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-9 w-20" />
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,34 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
}
return (
<Loader2
className={cn('animate-spin text-primary', sizeClasses[size], className)}
/>
)
}
interface FullPageLoaderProps {
text?: string
}
export function FullPageLoader({ text = 'Loading...' }: FullPageLoaderProps) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-muted-foreground">{text}</p>
</div>
)
}

View File

@@ -0,0 +1,226 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ProjectLogo } from './project-logo'
import { Upload, Loader2, Trash2, ImagePlus } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type LogoUploadProps = {
project: {
id: string
title: string
logoKey?: string | null
}
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export function LogoUpload({
project,
currentLogoUrl,
onUploadComplete,
children,
}: LogoUploadProps) {
const [open, setOpen] = useState(false)
const [preview, setPreview] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const getUploadUrl = trpc.logo.getUploadUrl.useMutation()
const confirmUpload = trpc.logo.confirmUpload.useMutation()
const deleteLogo = trpc.logo.delete.useMutation()
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate type
if (!ALLOWED_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
return
}
// Validate size
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
return
}
setSelectedFile(file)
// Create preview
const reader = new FileReader()
reader.onload = (e) => {
setPreview(e.target?.result as string)
}
reader.readAsDataURL(file)
}, [])
const handleUpload = async () => {
if (!selectedFile) return
setIsUploading(true)
try {
// Get pre-signed upload URL (includes provider type for tracking)
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
projectId: project.id,
fileName: selectedFile.name,
contentType: selectedFile.type,
})
// Upload file directly to storage
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: selectedFile,
headers: {
'Content-Type': selectedFile.type,
},
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload file')
}
// Confirm upload with the provider type that was used
await confirmUpload.mutateAsync({ projectId: project.id, key, providerType })
// Invalidate logo query
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo updated successfully')
setOpen(false)
setPreview(null)
setSelectedFile(null)
onUploadComplete?.()
} catch (error) {
console.error('Upload error:', error)
toast.error('Failed to upload logo. Please try again.')
} finally {
setIsUploading(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId: project.id })
utils.logo.getUrl.invalidate({ projectId: project.id })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const handleCancel = () => {
setPreview(null)
setSelectedFile(null)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children || (
<Button variant="outline" size="sm" className="gap-2">
<ImagePlus className="h-4 w-4" />
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Project Logo</DialogTitle>
<DialogDescription>
Upload a logo for &quot;{project.title}&quot;. Allowed formats: JPEG, PNG, GIF, WebP.
Max size: {MAX_SIZE_MB}MB.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Preview */}
<div className="flex justify-center">
<ProjectLogo
project={project}
logoUrl={preview || currentLogoUrl}
size="lg"
/>
</div>
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
ref={fileInputRef}
id="logo"
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !preview && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="flex-1"
>
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,55 @@
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface LogoProps {
variant?: 'small' | 'long'
className?: string
showText?: boolean
textSuffix?: string
}
export function Logo({
variant = 'small',
className,
showText = false,
textSuffix,
}: LogoProps) {
if (variant === 'long') {
return (
<div className={cn('flex items-center gap-2', className)}>
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC Logo"
width={120}
height={40}
className="h-8 w-auto"
priority
/>
{textSuffix && (
<span className="text-xs text-muted-foreground">{textSuffix}</span>
)}
</div>
)
}
return (
<div className={cn('flex items-center gap-3', className)}>
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC Logo"
width={32}
height={32}
className="h-8 w-8"
priority
/>
{showText && (
<div className="flex items-center gap-1">
<span className="font-semibold">MOPC</span>
{textSuffix && (
<span className="text-xs text-muted-foreground">{textSuffix}</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
interface PageHeaderProps {
title: string
description?: string
children?: React.ReactNode
className?: string
}
export function PageHeader({
title,
description,
children,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between',
className
)}
>
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
{children && <div className="flex items-center gap-2">{children}</div>}
</div>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import { ProjectLogo } from './project-logo'
import { trpc } from '@/lib/trpc/client'
type ProjectLogoWithUrlProps = {
project: {
id: string
title: string
logoKey?: string | null
}
size?: 'sm' | 'md' | 'lg'
fallback?: 'icon' | 'initials'
className?: string
}
/**
* Project logo component that fetches the URL automatically via tRPC.
* Use this in client components when you only have the project data without the URL.
*/
export function ProjectLogoWithUrl({
project,
size = 'md',
fallback = 'icon',
className,
}: ProjectLogoWithUrlProps) {
const { data: logoUrl } = trpc.logo.getUrl.useQuery(
{ projectId: project.id },
{
enabled: !!project.logoKey,
staleTime: 60 * 1000, // Cache for 1 minute
}
)
return (
<ProjectLogo
project={project}
logoUrl={logoUrl}
size={size}
fallback={fallback}
className={className}
/>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import { useState, useEffect } from 'react'
import { cn, getInitials } from '@/lib/utils'
import { ClipboardList } from 'lucide-react'
type ProjectLogoProps = {
project: {
title: string
logoKey?: string | null
}
logoUrl?: string | null
size?: 'sm' | 'md' | 'lg'
fallback?: 'icon' | 'initials'
className?: string
}
const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-16 w-16',
}
const iconSizeClasses = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-8 w-8',
}
const textSizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-lg',
}
export function ProjectLogo({
project,
logoUrl,
size = 'md',
fallback = 'icon',
className,
}: ProjectLogoProps) {
const [imageError, setImageError] = useState(false)
const initials = getInitials(project.title)
// Reset error state when logoUrl changes
useEffect(() => {
setImageError(false)
}, [logoUrl])
const showImage = logoUrl && !imageError
if (showImage) {
return (
<div
className={cn(
'relative overflow-hidden rounded-lg bg-muted',
sizeClasses[size],
className
)}
>
<img
src={logoUrl}
alt={`${project.title} logo`}
className="h-full w-full object-cover"
onError={() => setImageError(true)}
/>
</div>
)
}
// Fallback
return (
<div
className={cn(
'flex items-center justify-center rounded-lg bg-muted',
sizeClasses[size],
className
)}
>
{fallback === 'icon' ? (
<ClipboardList className={cn('text-muted-foreground', iconSizeClasses[size])} />
) : (
<span className={cn('font-medium text-muted-foreground', textSizeClasses[size])}>
{initials}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { cn } from '@/lib/utils'
import { CheckCircle, Circle, Clock } from 'lucide-react'
interface TimelineItem {
status: string
label: string
date: Date | string | null
completed: boolean
}
interface StatusTrackerProps {
timeline: TimelineItem[]
currentStatus: string
className?: string
}
export function StatusTracker({
timeline,
currentStatus,
className,
}: StatusTrackerProps) {
const formatDate = (date: Date | string | null) => {
if (!date) return null
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
return (
<div className={cn('relative', className)}>
<div className="space-y-0">
{timeline.map((item, index) => {
const isCompleted = item.completed
const isCurrent =
isCompleted && !timeline[index + 1]?.completed
const isPending = !isCompleted
return (
<div key={item.status} className="relative flex gap-4">
{/* Vertical line */}
{index < timeline.length - 1 && (
<div
className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5',
isCompleted ? 'bg-primary' : 'bg-muted'
)}
/>
)}
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
{isCompleted ? (
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
isCurrent
? 'bg-primary text-primary-foreground'
: 'bg-primary/20 text-primary'
)}
>
{isCurrent ? (
<Clock className="h-4 w-4" />
) : (
<CheckCircle className="h-4 w-4" />
)}
</div>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-muted bg-background">
<Circle className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 pb-8">
<div className="flex items-center gap-2">
<p
className={cn(
'font-medium',
isPending && 'text-muted-foreground'
)}
>
{item.label}
</p>
{isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current
</span>
)}
</div>
{item.date && (
<p className="text-sm text-muted-foreground">
{formatDate(item.date)}
</p>
)}
{isPending && !isCurrent && (
<p className="text-sm text-muted-foreground">Pending</p>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Compact horizontal version
interface StatusBarProps {
status: string
statuses: { value: string; label: string }[]
className?: string
}
export function StatusBar({ status, statuses, className }: StatusBarProps) {
const currentIndex = statuses.findIndex((s) => s.value === status)
return (
<div className={cn('flex items-center gap-2', className)}>
{statuses.map((s, index) => {
const isCompleted = index <= currentIndex
const isCurrent = index === currentIndex
return (
<div key={s.value} className="flex items-center gap-2">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8',
isCompleted ? 'bg-primary' : 'bg-muted'
)}
/>
)}
<div
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
isCurrent
? 'bg-primary text-primary-foreground'
: isCompleted
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{isCompleted && !isCurrent && (
<CheckCircle className="h-3 w-3" />
)}
{s.label}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,361 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { X, ChevronsUpDown, Check, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Tag {
id: string
name: string
description: string | null
category: string | null
color: string | null
isActive: boolean
}
interface TagInputProps {
value: string[]
onChange: (tags: string[]) => void
placeholder?: string
maxTags?: number
disabled?: boolean
className?: string
showCategories?: boolean
}
export function TagInput({
value,
onChange,
placeholder = 'Select tags...',
maxTags,
disabled = false,
className,
showCategories = true,
}: TagInputProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const { data: tagsData, isLoading } = trpc.tag.list.useQuery({
isActive: true,
})
const tags = tagsData?.tags || []
// Group tags by category
const tagsByCategory = tags.reduce(
(acc, tag) => {
const category = tag.category || 'Other'
if (!acc[category]) {
acc[category] = []
}
acc[category].push(tag)
return acc
},
{} as Record<string, Tag[]>
)
// Filter tags based on search
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(search.toLowerCase())
)
// Group filtered tags by category
const filteredByCategory = filteredTags.reduce(
(acc, tag) => {
const category = tag.category || 'Other'
if (!acc[category]) {
acc[category] = []
}
acc[category].push(tag)
return acc
},
{} as Record<string, Tag[]>
)
const handleSelect = useCallback(
(tagName: string) => {
if (value.includes(tagName)) {
onChange(value.filter((t) => t !== tagName))
} else {
if (maxTags && value.length >= maxTags) return
onChange([...value, tagName])
}
setSearch('')
},
[value, onChange, maxTags]
)
const handleRemove = useCallback(
(tagName: string) => {
onChange(value.filter((t) => t !== tagName))
},
[value, onChange]
)
// Get tag details for selected tags
const selectedTagDetails = value
.map((name) => tags.find((t) => t.name === name))
.filter((t) => t != null)
return (
<div className={cn('space-y-2', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'w-full justify-between font-normal',
!value.length && 'text-muted-foreground'
)}
>
{value.length > 0 ? `${value.length} tag(s) selected` : placeholder}
{isLoading ? (
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
) : (
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
ref={inputRef}
placeholder="Search tags..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
{isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Loading tags...
</div>
) : filteredTags.length === 0 ? (
<CommandEmpty>No tags found.</CommandEmpty>
) : showCategories ? (
// Show grouped by category
Object.entries(filteredByCategory)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, categoryTags]) => (
<CommandGroup key={category} heading={category}>
{categoryTags.map((tag) => {
const isSelected = value.includes(tag.name)
const isDisabled =
!isSelected && !!maxTags && value.length >= maxTags
return (
<CommandItem
key={tag.id}
value={tag.name}
onSelect={() => handleSelect(tag.name)}
disabled={isDisabled}
className={cn(
isDisabled && 'opacity-50 cursor-not-allowed'
)}
>
<div className="flex items-center gap-2 flex-1">
<div
className="h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: tag.color || '#6b7280',
}}
/>
<span>{tag.name}</span>
{tag.description && (
<span className="text-xs text-muted-foreground truncate">
- {tag.description}
</span>
)}
</div>
<Check
className={cn(
'ml-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
)
})}
</CommandGroup>
))
) : (
// Flat list
<CommandGroup>
{filteredTags.map((tag) => {
const isSelected = value.includes(tag.name)
const isDisabled =
!isSelected && !!maxTags && value.length >= maxTags
return (
<CommandItem
key={tag.id}
value={tag.name}
onSelect={() => handleSelect(tag.name)}
disabled={isDisabled}
className={cn(
isDisabled && 'opacity-50 cursor-not-allowed'
)}
>
<div className="flex items-center gap-2 flex-1">
<div
className="h-3 w-3 rounded-full shrink-0"
style={{
backgroundColor: tag.color || '#6b7280',
}}
/>
<span>{tag.name}</span>
</div>
<Check
className={cn(
'ml-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
)
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected tags display */}
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedTagDetails.length > 0
? selectedTagDetails.map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="gap-1 pr-1"
style={{
backgroundColor: tag.color
? `${tag.color}20`
: undefined,
borderColor: tag.color || undefined,
}}
>
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
{tag.name}
{!disabled && (
<button
type="button"
onClick={() => handleRemove(tag.name)}
className="ml-1 rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${tag.name}`}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
))
: // Fallback for tags not found in system (legacy data)
value.map((tagName) => (
<Badge key={tagName} variant="secondary" className="gap-1 pr-1">
{tagName}
{!disabled && (
<button
type="button"
onClick={() => handleRemove(tagName)}
className="ml-1 rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${tagName}`}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
))}
</div>
)}
{maxTags && (
<p className="text-xs text-muted-foreground">
{value.length} / {maxTags} tags selected
</p>
)}
</div>
)
}
// Simple variant for inline display (read-only)
interface TagDisplayProps {
tags: string[]
className?: string
maxDisplay?: number
}
export function TagDisplay({
tags,
className,
maxDisplay = 5,
}: TagDisplayProps) {
const { data: tagsData } = trpc.tag.list.useQuery({ isActive: true })
const allTags = tagsData?.tags || []
const displayTags = tags.slice(0, maxDisplay)
const remaining = tags.length - maxDisplay
const getTagColor = (name: string) => {
const tag = allTags.find((t) => t.name === name)
return tag?.color || '#6b7280'
}
if (tags.length === 0) {
return (
<span className={cn('text-muted-foreground text-sm', className)}>
No tags
</span>
)
}
return (
<div className={cn('flex flex-wrap gap-1', className)}>
{displayTags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs"
style={{
backgroundColor: `${getTagColor(tag)}20`,
borderColor: getTagColor(tag),
}}
>
<div
className="h-2 w-2 rounded-full mr-1"
style={{ backgroundColor: getTagColor(tag) }}
/>
{tag}
</Badge>
))}
{remaining > 0 && (
<Badge variant="outline" className="text-xs">
+{remaining} more
</Badge>
)}
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import { useState, useEffect } from 'react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { cn, getInitials } from '@/lib/utils'
import { Camera } from 'lucide-react'
type UserAvatarProps = {
user: {
name?: string | null
email?: string | null
profileImageKey?: string | null
}
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
className?: string
showEditOverlay?: boolean
avatarUrl?: string | null
}
const sizeClasses = {
xs: 'h-6 w-6',
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
}
const textSizeClasses = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
xl: 'text-lg',
}
const iconSizeClasses = {
xs: 'h-3 w-3',
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6',
}
export function UserAvatar({
user,
size = 'md',
className,
showEditOverlay = false,
avatarUrl,
}: UserAvatarProps) {
const [imageError, setImageError] = useState(false)
const initials = getInitials(user.name || user.email || 'U')
// Reset error state when avatarUrl changes
useEffect(() => {
setImageError(false)
}, [avatarUrl])
return (
<div className={cn('relative group', className)}>
<Avatar className={cn(sizeClasses[size])}>
{avatarUrl && !imageError ? (
<AvatarImage
src={avatarUrl}
alt={user.name || 'User avatar'}
onError={() => setImageError(true)}
/>
) : null}
<AvatarFallback className={cn(textSizeClasses[size])}>
{initials}
</AvatarFallback>
</Avatar>
{showEditOverlay && (
<div
className={cn(
'absolute inset-0 flex items-center justify-center rounded-full',
'bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity',
'cursor-pointer'
)}
>
<Camera className={cn('text-white', iconSizeClasses[size])} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-600",
warning:
"border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-600",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,49 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,40 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
warning:
'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
info: 'border-transparent bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90 active:scale-[0.98]',
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 active:scale-[0.98]',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 active:scale-[0.98]',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3 text-xs',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-xs',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-xs border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,12 @@
'use client'
import * as React from 'react'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,154 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-xs px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,297 @@
'use client'
import * as React from 'react'
import { CheckIcon, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
// Country data with ISO codes and flag emojis
const countries = [
{ code: 'AF', name: 'Afghanistan', flag: '\uD83C\uDDE6\uD83C\uDDEB' },
{ code: 'AL', name: 'Albania', flag: '\uD83C\uDDE6\uD83C\uDDF1' },
{ code: 'DZ', name: 'Algeria', flag: '\uD83C\uDDE9\uD83C\uDDFF' },
{ code: 'AD', name: 'Andorra', flag: '\uD83C\uDDE6\uD83C\uDDE9' },
{ code: 'AO', name: 'Angola', flag: '\uD83C\uDDE6\uD83C\uDDF4' },
{ code: 'AR', name: 'Argentina', flag: '\uD83C\uDDE6\uD83C\uDDF7' },
{ code: 'AM', name: 'Armenia', flag: '\uD83C\uDDE6\uD83C\uDDF2' },
{ code: 'AU', name: 'Australia', flag: '\uD83C\uDDE6\uD83C\uDDFA' },
{ code: 'AT', name: 'Austria', flag: '\uD83C\uDDE6\uD83C\uDDF9' },
{ code: 'AZ', name: 'Azerbaijan', flag: '\uD83C\uDDE6\uD83C\uDDFF' },
{ code: 'BS', name: 'Bahamas', flag: '\uD83C\uDDE7\uD83C\uDDF8' },
{ code: 'BH', name: 'Bahrain', flag: '\uD83C\uDDE7\uD83C\uDDED' },
{ code: 'BD', name: 'Bangladesh', flag: '\uD83C\uDDE7\uD83C\uDDE9' },
{ code: 'BB', name: 'Barbados', flag: '\uD83C\uDDE7\uD83C\uDDE7' },
{ code: 'BY', name: 'Belarus', flag: '\uD83C\uDDE7\uD83C\uDDFE' },
{ code: 'BE', name: 'Belgium', flag: '\uD83C\uDDE7\uD83C\uDDEA' },
{ code: 'BZ', name: 'Belize', flag: '\uD83C\uDDE7\uD83C\uDDFF' },
{ code: 'BJ', name: 'Benin', flag: '\uD83C\uDDE7\uD83C\uDDEF' },
{ code: 'BT', name: 'Bhutan', flag: '\uD83C\uDDE7\uD83C\uDDF9' },
{ code: 'BO', name: 'Bolivia', flag: '\uD83C\uDDE7\uD83C\uDDF4' },
{ code: 'BA', name: 'Bosnia and Herzegovina', flag: '\uD83C\uDDE7\uD83C\uDDE6' },
{ code: 'BW', name: 'Botswana', flag: '\uD83C\uDDE7\uD83C\uDDFC' },
{ code: 'BR', name: 'Brazil', flag: '\uD83C\uDDE7\uD83C\uDDF7' },
{ code: 'BN', name: 'Brunei', flag: '\uD83C\uDDE7\uD83C\uDDF3' },
{ code: 'BG', name: 'Bulgaria', flag: '\uD83C\uDDE7\uD83C\uDDEC' },
{ code: 'BF', name: 'Burkina Faso', flag: '\uD83C\uDDE7\uD83C\uDDEB' },
{ code: 'BI', name: 'Burundi', flag: '\uD83C\uDDE7\uD83C\uDDEE' },
{ code: 'KH', name: 'Cambodia', flag: '\uD83C\uDDF0\uD83C\uDDED' },
{ code: 'CM', name: 'Cameroon', flag: '\uD83C\uDDE8\uD83C\uDDF2' },
{ code: 'CA', name: 'Canada', flag: '\uD83C\uDDE8\uD83C\uDDE6' },
{ code: 'CV', name: 'Cape Verde', flag: '\uD83C\uDDE8\uD83C\uDDFB' },
{ code: 'CF', name: 'Central African Republic', flag: '\uD83C\uDDE8\uD83C\uDDEB' },
{ code: 'TD', name: 'Chad', flag: '\uD83C\uDDF9\uD83C\uDDE9' },
{ code: 'CL', name: 'Chile', flag: '\uD83C\uDDE8\uD83C\uDDF1' },
{ code: 'CN', name: 'China', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
{ code: 'CO', name: 'Colombia', flag: '\uD83C\uDDE8\uD83C\uDDF4' },
{ code: 'KM', name: 'Comoros', flag: '\uD83C\uDDF0\uD83C\uDDF2' },
{ code: 'CG', name: 'Congo', flag: '\uD83C\uDDE8\uD83C\uDDEC' },
{ code: 'CR', name: 'Costa Rica', flag: '\uD83C\uDDE8\uD83C\uDDF7' },
{ code: 'HR', name: 'Croatia', flag: '\uD83C\uDDED\uD83C\uDDF7' },
{ code: 'CU', name: 'Cuba', flag: '\uD83C\uDDE8\uD83C\uDDFA' },
{ code: 'CY', name: 'Cyprus', flag: '\uD83C\uDDE8\uD83C\uDDFE' },
{ code: 'CZ', name: 'Czech Republic', flag: '\uD83C\uDDE8\uD83C\uDDFF' },
{ code: 'DK', name: 'Denmark', flag: '\uD83C\uDDE9\uD83C\uDDF0' },
{ code: 'DJ', name: 'Djibouti', flag: '\uD83C\uDDE9\uD83C\uDDEF' },
{ code: 'DM', name: 'Dominica', flag: '\uD83C\uDDE9\uD83C\uDDF2' },
{ code: 'DO', name: 'Dominican Republic', flag: '\uD83C\uDDE9\uD83C\uDDF4' },
{ code: 'EC', name: 'Ecuador', flag: '\uD83C\uDDEA\uD83C\uDDE8' },
{ code: 'EG', name: 'Egypt', flag: '\uD83C\uDDEA\uD83C\uDDEC' },
{ code: 'SV', name: 'El Salvador', flag: '\uD83C\uDDF8\uD83C\uDDFB' },
{ code: 'GQ', name: 'Equatorial Guinea', flag: '\uD83C\uDDEC\uD83C\uDDF6' },
{ code: 'ER', name: 'Eritrea', flag: '\uD83C\uDDEA\uD83C\uDDF7' },
{ code: 'EE', name: 'Estonia', flag: '\uD83C\uDDEA\uD83C\uDDEA' },
{ code: 'ET', name: 'Ethiopia', flag: '\uD83C\uDDEA\uD83C\uDDF9' },
{ code: 'FJ', name: 'Fiji', flag: '\uD83C\uDDEB\uD83C\uDDEF' },
{ code: 'FI', name: 'Finland', flag: '\uD83C\uDDEB\uD83C\uDDEE' },
{ code: 'FR', name: 'France', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
{ code: 'GA', name: 'Gabon', flag: '\uD83C\uDDEC\uD83C\uDDE6' },
{ code: 'GM', name: 'Gambia', flag: '\uD83C\uDDEC\uD83C\uDDF2' },
{ code: 'GE', name: 'Georgia', flag: '\uD83C\uDDEC\uD83C\uDDEA' },
{ code: 'DE', name: 'Germany', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
{ code: 'GH', name: 'Ghana', flag: '\uD83C\uDDEC\uD83C\uDDED' },
{ code: 'GR', name: 'Greece', flag: '\uD83C\uDDEC\uD83C\uDDF7' },
{ code: 'GD', name: 'Grenada', flag: '\uD83C\uDDEC\uD83C\uDDE9' },
{ code: 'GT', name: 'Guatemala', flag: '\uD83C\uDDEC\uD83C\uDDF9' },
{ code: 'GN', name: 'Guinea', flag: '\uD83C\uDDEC\uD83C\uDDF3' },
{ code: 'GW', name: 'Guinea-Bissau', flag: '\uD83C\uDDEC\uD83C\uDDFC' },
{ code: 'GY', name: 'Guyana', flag: '\uD83C\uDDEC\uD83C\uDDFE' },
{ code: 'HT', name: 'Haiti', flag: '\uD83C\uDDED\uD83C\uDDF9' },
{ code: 'HN', name: 'Honduras', flag: '\uD83C\uDDED\uD83C\uDDF3' },
{ code: 'HK', name: 'Hong Kong', flag: '\uD83C\uDDED\uD83C\uDDF0' },
{ code: 'HU', name: 'Hungary', flag: '\uD83C\uDDED\uD83C\uDDFA' },
{ code: 'IS', name: 'Iceland', flag: '\uD83C\uDDEE\uD83C\uDDF8' },
{ code: 'IN', name: 'India', flag: '\uD83C\uDDEE\uD83C\uDDF3' },
{ code: 'ID', name: 'Indonesia', flag: '\uD83C\uDDEE\uD83C\uDDE9' },
{ code: 'IR', name: 'Iran', flag: '\uD83C\uDDEE\uD83C\uDDF7' },
{ code: 'IQ', name: 'Iraq', flag: '\uD83C\uDDEE\uD83C\uDDF6' },
{ code: 'IE', name: 'Ireland', flag: '\uD83C\uDDEE\uD83C\uDDEA' },
{ code: 'IL', name: 'Israel', flag: '\uD83C\uDDEE\uD83C\uDDF1' },
{ code: 'IT', name: 'Italy', flag: '\uD83C\uDDEE\uD83C\uDDF9' },
{ code: 'CI', name: 'Ivory Coast', flag: '\uD83C\uDDE8\uD83C\uDDEE' },
{ code: 'JM', name: 'Jamaica', flag: '\uD83C\uDDEF\uD83C\uDDF2' },
{ code: 'JP', name: 'Japan', flag: '\uD83C\uDDEF\uD83C\uDDF5' },
{ code: 'JO', name: 'Jordan', flag: '\uD83C\uDDEF\uD83C\uDDF4' },
{ code: 'KZ', name: 'Kazakhstan', flag: '\uD83C\uDDF0\uD83C\uDDFF' },
{ code: 'KE', name: 'Kenya', flag: '\uD83C\uDDF0\uD83C\uDDEA' },
{ code: 'KI', name: 'Kiribati', flag: '\uD83C\uDDF0\uD83C\uDDEE' },
{ code: 'KW', name: 'Kuwait', flag: '\uD83C\uDDF0\uD83C\uDDFC' },
{ code: 'KG', name: 'Kyrgyzstan', flag: '\uD83C\uDDF0\uD83C\uDDEC' },
{ code: 'LA', name: 'Laos', flag: '\uD83C\uDDF1\uD83C\uDDE6' },
{ code: 'LV', name: 'Latvia', flag: '\uD83C\uDDF1\uD83C\uDDFB' },
{ code: 'LB', name: 'Lebanon', flag: '\uD83C\uDDF1\uD83C\uDDE7' },
{ code: 'LS', name: 'Lesotho', flag: '\uD83C\uDDF1\uD83C\uDDF8' },
{ code: 'LR', name: 'Liberia', flag: '\uD83C\uDDF1\uD83C\uDDF7' },
{ code: 'LY', name: 'Libya', flag: '\uD83C\uDDF1\uD83C\uDDFE' },
{ code: 'LI', name: 'Liechtenstein', flag: '\uD83C\uDDF1\uD83C\uDDEE' },
{ code: 'LT', name: 'Lithuania', flag: '\uD83C\uDDF1\uD83C\uDDF9' },
{ code: 'LU', name: 'Luxembourg', flag: '\uD83C\uDDF1\uD83C\uDDFA' },
{ code: 'MO', name: 'Macau', flag: '\uD83C\uDDF2\uD83C\uDDF4' },
{ code: 'MK', name: 'Macedonia', flag: '\uD83C\uDDF2\uD83C\uDDF0' },
{ code: 'MG', name: 'Madagascar', flag: '\uD83C\uDDF2\uD83C\uDDEC' },
{ code: 'MW', name: 'Malawi', flag: '\uD83C\uDDF2\uD83C\uDDFC' },
{ code: 'MY', name: 'Malaysia', flag: '\uD83C\uDDF2\uD83C\uDDFE' },
{ code: 'MV', name: 'Maldives', flag: '\uD83C\uDDF2\uD83C\uDDFB' },
{ code: 'ML', name: 'Mali', flag: '\uD83C\uDDF2\uD83C\uDDF1' },
{ code: 'MT', name: 'Malta', flag: '\uD83C\uDDF2\uD83C\uDDF9' },
{ code: 'MH', name: 'Marshall Islands', flag: '\uD83C\uDDF2\uD83C\uDDED' },
{ code: 'MR', name: 'Mauritania', flag: '\uD83C\uDDF2\uD83C\uDDF7' },
{ code: 'MU', name: 'Mauritius', flag: '\uD83C\uDDF2\uD83C\uDDFA' },
{ code: 'MX', name: 'Mexico', flag: '\uD83C\uDDF2\uD83C\uDDFD' },
{ code: 'FM', name: 'Micronesia', flag: '\uD83C\uDDEB\uD83C\uDDF2' },
{ code: 'MD', name: 'Moldova', flag: '\uD83C\uDDF2\uD83C\uDDE9' },
{ code: 'MC', name: 'Monaco', flag: '\uD83C\uDDF2\uD83C\uDDE8' },
{ code: 'MN', name: 'Mongolia', flag: '\uD83C\uDDF2\uD83C\uDDF3' },
{ code: 'ME', name: 'Montenegro', flag: '\uD83C\uDDF2\uD83C\uDDEA' },
{ code: 'MA', name: 'Morocco', flag: '\uD83C\uDDF2\uD83C\uDDE6' },
{ code: 'MZ', name: 'Mozambique', flag: '\uD83C\uDDF2\uD83C\uDDFF' },
{ code: 'MM', name: 'Myanmar', flag: '\uD83C\uDDF2\uD83C\uDDF2' },
{ code: 'NA', name: 'Namibia', flag: '\uD83C\uDDF3\uD83C\uDDE6' },
{ code: 'NR', name: 'Nauru', flag: '\uD83C\uDDF3\uD83C\uDDF7' },
{ code: 'NP', name: 'Nepal', flag: '\uD83C\uDDF3\uD83C\uDDF5' },
{ code: 'NL', name: 'Netherlands', flag: '\uD83C\uDDF3\uD83C\uDDF1' },
{ code: 'NZ', name: 'New Zealand', flag: '\uD83C\uDDF3\uD83C\uDDFF' },
{ code: 'NI', name: 'Nicaragua', flag: '\uD83C\uDDF3\uD83C\uDDEE' },
{ code: 'NE', name: 'Niger', flag: '\uD83C\uDDF3\uD83C\uDDEA' },
{ code: 'NG', name: 'Nigeria', flag: '\uD83C\uDDF3\uD83C\uDDEC' },
{ code: 'KP', name: 'North Korea', flag: '\uD83C\uDDF0\uD83C\uDDF5' },
{ code: 'NO', name: 'Norway', flag: '\uD83C\uDDF3\uD83C\uDDF4' },
{ code: 'OM', name: 'Oman', flag: '\uD83C\uDDF4\uD83C\uDDF2' },
{ code: 'PK', name: 'Pakistan', flag: '\uD83C\uDDF5\uD83C\uDDF0' },
{ code: 'PW', name: 'Palau', flag: '\uD83C\uDDF5\uD83C\uDDFC' },
{ code: 'PS', name: 'Palestine', flag: '\uD83C\uDDF5\uD83C\uDDF8' },
{ code: 'PA', name: 'Panama', flag: '\uD83C\uDDF5\uD83C\uDDE6' },
{ code: 'PG', name: 'Papua New Guinea', flag: '\uD83C\uDDF5\uD83C\uDDEC' },
{ code: 'PY', name: 'Paraguay', flag: '\uD83C\uDDF5\uD83C\uDDFE' },
{ code: 'PE', name: 'Peru', flag: '\uD83C\uDDF5\uD83C\uDDEA' },
{ code: 'PH', name: 'Philippines', flag: '\uD83C\uDDF5\uD83C\uDDED' },
{ code: 'PL', name: 'Poland', flag: '\uD83C\uDDF5\uD83C\uDDF1' },
{ code: 'PT', name: 'Portugal', flag: '\uD83C\uDDF5\uD83C\uDDF9' },
{ code: 'QA', name: 'Qatar', flag: '\uD83C\uDDF6\uD83C\uDDE6' },
{ code: 'RO', name: 'Romania', flag: '\uD83C\uDDF7\uD83C\uDDF4' },
{ code: 'RU', name: 'Russia', flag: '\uD83C\uDDF7\uD83C\uDDFA' },
{ code: 'RW', name: 'Rwanda', flag: '\uD83C\uDDF7\uD83C\uDDFC' },
{ code: 'KN', name: 'Saint Kitts and Nevis', flag: '\uD83C\uDDF0\uD83C\uDDF3' },
{ code: 'LC', name: 'Saint Lucia', flag: '\uD83C\uDDF1\uD83C\uDDE8' },
{ code: 'VC', name: 'Saint Vincent and the Grenadines', flag: '\uD83C\uDDFB\uD83C\uDDE8' },
{ code: 'WS', name: 'Samoa', flag: '\uD83C\uDDFC\uD83C\uDDF8' },
{ code: 'SM', name: 'San Marino', flag: '\uD83C\uDDF8\uD83C\uDDF2' },
{ code: 'ST', name: 'Sao Tome and Principe', flag: '\uD83C\uDDF8\uD83C\uDDF9' },
{ code: 'SA', name: 'Saudi Arabia', flag: '\uD83C\uDDF8\uD83C\uDDE6' },
{ code: 'SN', name: 'Senegal', flag: '\uD83C\uDDF8\uD83C\uDDF3' },
{ code: 'RS', name: 'Serbia', flag: '\uD83C\uDDF7\uD83C\uDDF8' },
{ code: 'SC', name: 'Seychelles', flag: '\uD83C\uDDF8\uD83C\uDDE8' },
{ code: 'SL', name: 'Sierra Leone', flag: '\uD83C\uDDF8\uD83C\uDDF1' },
{ code: 'SG', name: 'Singapore', flag: '\uD83C\uDDF8\uD83C\uDDEC' },
{ code: 'SK', name: 'Slovakia', flag: '\uD83C\uDDF8\uD83C\uDDF0' },
{ code: 'SI', name: 'Slovenia', flag: '\uD83C\uDDF8\uD83C\uDDEE' },
{ code: 'SB', name: 'Solomon Islands', flag: '\uD83C\uDDF8\uD83C\uDDE7' },
{ code: 'SO', name: 'Somalia', flag: '\uD83C\uDDF8\uD83C\uDDF4' },
{ code: 'ZA', name: 'South Africa', flag: '\uD83C\uDDFF\uD83C\uDDE6' },
{ code: 'KR', name: 'South Korea', flag: '\uD83C\uDDF0\uD83C\uDDF7' },
{ code: 'SS', name: 'South Sudan', flag: '\uD83C\uDDF8\uD83C\uDDF8' },
{ code: 'ES', name: 'Spain', flag: '\uD83C\uDDEA\uD83C\uDDF8' },
{ code: 'LK', name: 'Sri Lanka', flag: '\uD83C\uDDF1\uD83C\uDDF0' },
{ code: 'SD', name: 'Sudan', flag: '\uD83C\uDDF8\uD83C\uDDE9' },
{ code: 'SR', name: 'Suriname', flag: '\uD83C\uDDF8\uD83C\uDDF7' },
{ code: 'SZ', name: 'Swaziland', flag: '\uD83C\uDDF8\uD83C\uDDFF' },
{ code: 'SE', name: 'Sweden', flag: '\uD83C\uDDF8\uD83C\uDDEA' },
{ code: 'CH', name: 'Switzerland', flag: '\uD83C\uDDE8\uD83C\uDDED' },
{ code: 'SY', name: 'Syria', flag: '\uD83C\uDDF8\uD83C\uDDFE' },
{ code: 'TW', name: 'Taiwan', flag: '\uD83C\uDDF9\uD83C\uDDFC' },
{ code: 'TJ', name: 'Tajikistan', flag: '\uD83C\uDDF9\uD83C\uDDEF' },
{ code: 'TZ', name: 'Tanzania', flag: '\uD83C\uDDF9\uD83C\uDDFF' },
{ code: 'TH', name: 'Thailand', flag: '\uD83C\uDDF9\uD83C\uDDED' },
{ code: 'TL', name: 'Timor-Leste', flag: '\uD83C\uDDF9\uD83C\uDDF1' },
{ code: 'TG', name: 'Togo', flag: '\uD83C\uDDF9\uD83C\uDDEC' },
{ code: 'TO', name: 'Tonga', flag: '\uD83C\uDDF9\uD83C\uDDF4' },
{ code: 'TT', name: 'Trinidad and Tobago', flag: '\uD83C\uDDF9\uD83C\uDDF9' },
{ code: 'TN', name: 'Tunisia', flag: '\uD83C\uDDF9\uD83C\uDDF3' },
{ code: 'TR', name: 'Turkey', flag: '\uD83C\uDDF9\uD83C\uDDF7' },
{ code: 'TM', name: 'Turkmenistan', flag: '\uD83C\uDDF9\uD83C\uDDF2' },
{ code: 'TV', name: 'Tuvalu', flag: '\uD83C\uDDF9\uD83C\uDDFB' },
{ code: 'UG', name: 'Uganda', flag: '\uD83C\uDDFA\uD83C\uDDEC' },
{ code: 'UA', name: 'Ukraine', flag: '\uD83C\uDDFA\uD83C\uDDE6' },
{ code: 'AE', name: 'United Arab Emirates', flag: '\uD83C\uDDE6\uD83C\uDDEA' },
{ code: 'GB', name: 'United Kingdom', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
{ code: 'US', name: 'United States', flag: '\uD83C\uDDFA\uD83C\uDDF8' },
{ code: 'UY', name: 'Uruguay', flag: '\uD83C\uDDFA\uD83C\uDDFE' },
{ code: 'UZ', name: 'Uzbekistan', flag: '\uD83C\uDDFA\uD83C\uDDFF' },
{ code: 'VU', name: 'Vanuatu', flag: '\uD83C\uDDFB\uD83C\uDDFA' },
{ code: 'VA', name: 'Vatican City', flag: '\uD83C\uDDFB\uD83C\uDDE6' },
{ code: 'VE', name: 'Venezuela', flag: '\uD83C\uDDFB\uD83C\uDDEA' },
{ code: 'VN', name: 'Vietnam', flag: '\uD83C\uDDFB\uD83C\uDDF3' },
{ code: 'YE', name: 'Yemen', flag: '\uD83C\uDDFE\uD83C\uDDEA' },
{ code: 'ZM', name: 'Zambia', flag: '\uD83C\uDDFF\uD83C\uDDF2' },
{ code: 'ZW', name: 'Zimbabwe', flag: '\uD83C\uDDFF\uD83C\uDDFC' },
]
export type Country = (typeof countries)[number]
interface CountrySelectProps {
value?: string
onChange?: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
const CountrySelect = React.forwardRef<HTMLButtonElement, CountrySelectProps>(
({ value, onChange, placeholder = 'Select country...', disabled, className }, ref) => {
const [open, setOpen] = React.useState(false)
const selectedCountry = countries.find((c) => c.code === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', className)}
disabled={disabled}
>
{selectedCountry ? (
<span className="flex items-center gap-2">
<span className="text-lg leading-none">{selectedCountry.flag}</span>
<span>{selectedCountry.name}</span>
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countries.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
onChange?.(country.code)
setOpen(false)
}}
className="gap-2"
>
<span className="text-lg leading-none">{country.flag}</span>
<span className="flex-1">{country.name}</span>
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
value === country.code ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
)
CountrySelect.displayName = 'CountrySelect'
export { CountrySelect, countries }

View File

@@ -0,0 +1,118 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,195 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-xs px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-xs px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,178 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = 'FormItem'
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = 'FormLabel'
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = 'FormControl'
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
})
FormDescription.displayName = 'FormDescription'
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = 'FormMessage'
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,25 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,176 @@
'use client'
import * as React from 'react'
import { CheckIcon, ChevronsUpDown } from 'lucide-react'
import * as RPNInput from 'react-phone-number-input'
import flags from 'react-phone-number-input/flags'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
type PhoneInputProps = Omit<
React.ComponentProps<'input'>,
'onChange' | 'value' | 'ref'
> &
Omit<RPNInput.Props<typeof RPNInput.default>, 'onChange'> & {
onChange?: (value: RPNInput.Value) => void
}
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn('flex', className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
smartCaret={false}
onChange={(value) => onChange?.(value || ('' as RPNInput.Value))}
{...props}
/>
)
}
)
PhoneInput.displayName = 'PhoneInput'
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<'input'>
>(({ className, ...props }, ref) => (
<Input
className={cn('rounded-e-lg rounded-s-none', className)}
{...props}
ref={ref}
/>
))
InputComponent.displayName = 'InputComponent'
type CountryEntry = { label: string; value: RPNInput.Country | undefined }
type CountrySelectProps = {
disabled?: boolean
value: RPNInput.Country
options: CountryEntry[]
onChange: (country: RPNInput.Country) => void
}
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
'-mr-2 size-4 opacity-50',
disabled ? 'hidden' : 'opacity-100'
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
setOpen={setOpen}
/>
) : null
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
interface CountrySelectOptionProps {
country: RPNInput.Country
countryName: string
selectedCountry: RPNInput.Country
onChange: (country: RPNInput.Country) => void
setOpen: (open: boolean) => void
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
setOpen,
}: CountrySelectOptionProps) => {
return (
<CommandItem
className="gap-2"
onSelect={() => {
onChange(country)
setOpen(false)
}}
>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-muted-foreground">
{`+${RPNInput.getCountryCallingCode(country)}`}
</span>
<CheckIcon
className={`ml-auto size-4 ${country === selectedCountry ? 'opacity-100' : 'opacity-0'}`}
/>
</CommandItem>
)
}
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country]
return (
<span className="flex h-4 w-6 overflow-hidden rounded-xs bg-foreground/20 [&_svg]:size-full">
{Flag && <Flag title={countryName} />}
</span>
)
}
export { PhoneInput }

View File

@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,27 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,43 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ComponentRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,47 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,156 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-xs py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,30 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
// Note: requires @radix-ui/react-slider to be installed
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
))
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
))
TableCaption.displayName = 'TableCaption'
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,54 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-xs px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }