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:
196
src/components/admin/user-actions.tsx
Normal file
196
src/components/admin/user-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
src/components/charts/criteria-scores.tsx
Normal file
106
src/components/charts/criteria-scores.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
src/components/charts/evaluation-timeline.tsx
Normal file
102
src/components/charts/evaluation-timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
src/components/charts/geographic-distribution.tsx
Normal file
135
src/components/charts/geographic-distribution.tsx
Normal 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 · {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>
|
||||
)
|
||||
}
|
||||
50
src/components/charts/geographic-summary-card.tsx
Normal file
50
src/components/charts/geographic-summary-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
src/components/charts/index.ts
Normal file
8
src/components/charts/index.ts
Normal 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'
|
||||
102
src/components/charts/juror-workload.tsx
Normal file
102
src/components/charts/juror-workload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/components/charts/leaflet-map.tsx
Normal file
78
src/components/charts/leaflet-map.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
src/components/charts/project-rankings.tsx
Normal file
121
src/components/charts/project-rankings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/charts/score-distribution.tsx
Normal file
92
src/components/charts/score-distribution.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
src/components/charts/status-breakdown.tsx
Normal file
131
src/components/charts/status-breakdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/components/forms/apply-steps/index.ts
Normal file
6
src/components/forms/apply-steps/index.ts
Normal 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'
|
||||
138
src/components/forms/apply-steps/step-additional.tsx
Normal file
138
src/components/forms/apply-steps/step-additional.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
src/components/forms/apply-steps/step-contact.tsx
Normal file
111
src/components/forms/apply-steps/step-contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/components/forms/apply-steps/step-project.tsx
Normal file
141
src/components/forms/apply-steps/step-project.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
213
src/components/forms/apply-steps/step-review.tsx
Normal file
213
src/components/forms/apply-steps/step-review.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
src/components/forms/apply-steps/step-team.tsx
Normal file
184
src/components/forms/apply-steps/step-team.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
132
src/components/forms/apply-steps/step-welcome.tsx
Normal file
132
src/components/forms/apply-steps/step-welcome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
627
src/components/forms/csv-import-form.tsx
Normal file
627
src/components/forms/csv-import-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
516
src/components/forms/evaluation-form-builder.tsx
Normal file
516
src/components/forms/evaluation-form-builder.tsx
Normal 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 "{criterion.label}"?
|
||||
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>
|
||||
)
|
||||
}
|
||||
600
src/components/forms/evaluation-form.tsx
Normal file
600
src/components/forms/evaluation-form.tsx
Normal 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
|
||||
}
|
||||
326
src/components/forms/form-wizard.tsx
Normal file
326
src/components/forms/form-wizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
489
src/components/forms/notion-import-form.tsx
Normal file
489
src/components/forms/notion-import-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
513
src/components/forms/round-type-settings.tsx
Normal file
513
src/components/forms/round-type-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
516
src/components/forms/typeform-import-form.tsx
Normal file
516
src/components/forms/typeform-import-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/layouts/admin-edition-wrapper.tsx
Normal file
20
src/components/layouts/admin-edition-wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
304
src/components/layouts/admin-sidebar.tsx
Normal file
304
src/components/layouts/admin-sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
176
src/components/layouts/jury-nav.tsx
Normal file
176
src/components/layouts/jury-nav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
177
src/components/layouts/mentor-nav.tsx
Normal file
177
src/components/layouts/mentor-nav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
162
src/components/layouts/observer-nav.tsx
Normal file
162
src/components/layouts/observer-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
src/components/settings/ai-settings-form.tsx
Normal file
258
src/components/settings/ai-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
src/components/settings/branding-settings-form.tsx
Normal file
219
src/components/settings/branding-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
181
src/components/settings/defaults-settings-form.tsx
Normal file
181
src/components/settings/defaults-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
270
src/components/settings/email-settings-form.tsx
Normal file
270
src/components/settings/email-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
src/components/settings/security-settings-form.tsx
Normal file
149
src/components/settings/security-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
224
src/components/settings/settings-content.tsx
Normal file
224
src/components/settings/settings-content.tsx
Normal 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 }
|
||||
293
src/components/settings/storage-settings-form.tsx
Normal file
293
src/components/settings/storage-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
224
src/components/shared/avatar-upload.tsx
Normal file
224
src/components/shared/avatar-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
src/components/shared/block-editor.tsx
Normal file
136
src/components/shared/block-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
src/components/shared/edition-selector.tsx
Normal file
178
src/components/shared/edition-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/components/shared/empty-state.tsx
Normal file
49
src/components/shared/empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
462
src/components/shared/file-upload.tsx
Normal file
462
src/components/shared/file-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
344
src/components/shared/file-viewer.tsx
Normal file
344
src/components/shared/file-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/shared/loading-spinner.tsx
Normal file
34
src/components/shared/loading-spinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
226
src/components/shared/logo-upload.tsx
Normal file
226
src/components/shared/logo-upload.tsx
Normal 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 "{project.title}". 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>
|
||||
)
|
||||
}
|
||||
55
src/components/shared/logo.tsx
Normal file
55
src/components/shared/logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/components/shared/page-header.tsx
Normal file
32
src/components/shared/page-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/shared/project-logo-with-url.tsx
Normal file
44
src/components/shared/project-logo-with-url.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
src/components/shared/project-logo.tsx
Normal file
90
src/components/shared/project-logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/components/shared/status-tracker.tsx
Normal file
159
src/components/shared/status-tracker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
361
src/components/shared/tag-input.tsx
Normal file
361
src/components/shared/tag-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
src/components/shared/user-avatar.tsx
Normal file
87
src/components/shared/user-avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
62
src/components/ui/alert.tsx
Normal file
62
src/components/ui/alert.tsx
Normal 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 }
|
||||
49
src/components/ui/avatar.tsx
Normal file
49
src/components/ui/avatar.tsx
Normal 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 }
|
||||
40
src/components/ui/badge.tsx
Normal file
40
src/components/ui/badge.tsx
Normal 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 }
|
||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 }
|
||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal 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 }
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
12
src/components/ui/collapsible.tsx
Normal file
12
src/components/ui/collapsible.tsx
Normal 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 }
|
||||
154
src/components/ui/command.tsx
Normal file
154
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
297
src/components/ui/country-select.tsx
Normal file
297
src/components/ui/country-select.tsx
Normal 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 }
|
||||
118
src/components/ui/dialog.tsx
Normal file
118
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
195
src/components/ui/dropdown-menu.tsx
Normal file
195
src/components/ui/dropdown-menu.tsx
Normal 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
178
src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
25
src/components/ui/label.tsx
Normal file
25
src/components/ui/label.tsx
Normal 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 }
|
||||
176
src/components/ui/phone-input.tsx
Normal file
176
src/components/ui/phone-input.tsx
Normal 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 }
|
||||
30
src/components/ui/popover.tsx
Normal file
30
src/components/ui/popover.tsx
Normal 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 }
|
||||
27
src/components/ui/progress.tsx
Normal file
27
src/components/ui/progress.tsx
Normal 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 }
|
||||
43
src/components/ui/radio-group.tsx
Normal file
43
src/components/ui/radio-group.tsx
Normal 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 }
|
||||
47
src/components/ui/scroll-area.tsx
Normal file
47
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
156
src/components/ui/select.tsx
Normal file
156
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
30
src/components/ui/separator.tsx
Normal file
30
src/components/ui/separator.tsx
Normal 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 }
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
28
src/components/ui/slider.tsx
Normal file
28
src/components/ui/slider.tsx
Normal 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 }
|
||||
28
src/components/ui/switch.tsx
Normal file
28
src/components/ui/switch.tsx
Normal 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
116
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal 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 }
|
||||
21
src/components/ui/textarea.tsx
Normal file
21
src/components/ui/textarea.tsx
Normal 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 }
|
||||
31
src/components/ui/tooltip.tsx
Normal file
31
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user