Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,215 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Converts a camelCase or snake_case column name to Title Case.
|
||||
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
|
||||
*/
|
||||
function formatColumnName(col: string): string {
|
||||
// Replace underscores with spaces
|
||||
let result = col.replace(/_/g, ' ')
|
||||
// Insert space before uppercase letters (camelCase -> spaced)
|
||||
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// Capitalize first letter of each word
|
||||
return result
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
type ExportData = {
|
||||
data: Record<string, unknown>[]
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
type CsvExportDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
exportData: ExportData | undefined
|
||||
isLoading: boolean
|
||||
filename: string
|
||||
onRequestData: () => Promise<ExportData | undefined>
|
||||
}
|
||||
|
||||
export function CsvExportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
exportData,
|
||||
isLoading,
|
||||
filename,
|
||||
onRequestData,
|
||||
}: CsvExportDialogProps) {
|
||||
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
|
||||
const [dataLoaded, setDataLoaded] = useState(false)
|
||||
|
||||
// When dialog opens, fetch data if not already loaded
|
||||
useEffect(() => {
|
||||
if (open && !dataLoaded) {
|
||||
onRequestData().then((result) => {
|
||||
if (result?.columns) {
|
||||
setSelectedColumns(new Set(result.columns))
|
||||
}
|
||||
setDataLoaded(true)
|
||||
})
|
||||
}
|
||||
}, [open, dataLoaded, onRequestData])
|
||||
|
||||
// Sync selected columns when export data changes
|
||||
useEffect(() => {
|
||||
if (exportData?.columns) {
|
||||
setSelectedColumns(new Set(exportData.columns))
|
||||
}
|
||||
}, [exportData])
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDataLoaded(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const toggleColumn = (col: string, checked: boolean) => {
|
||||
const next = new Set(selectedColumns)
|
||||
if (checked) {
|
||||
next.add(col)
|
||||
} else {
|
||||
next.delete(col)
|
||||
}
|
||||
setSelectedColumns(next)
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (!exportData) return
|
||||
if (selectedColumns.size === exportData.columns.length) {
|
||||
setSelectedColumns(new Set())
|
||||
} else {
|
||||
setSelectedColumns(new Set(exportData.columns))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!exportData) return
|
||||
|
||||
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
|
||||
|
||||
// Build CSV header with formatted names
|
||||
const csvHeader = columnsArray.map((col) => {
|
||||
const formatted = formatColumnName(col)
|
||||
// Escape quotes in header
|
||||
if (formatted.includes(',') || formatted.includes('"')) {
|
||||
return `"${formatted.replace(/"/g, '""')}"`
|
||||
}
|
||||
return formatted
|
||||
})
|
||||
|
||||
const csvContent = [
|
||||
csvHeader.join(','),
|
||||
...exportData.data.map((row) =>
|
||||
columnsArray
|
||||
.map((col) => {
|
||||
const value = row[col]
|
||||
if (value === null || value === undefined) return ''
|
||||
const str = String(value)
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// Delay revoking to ensure download starts before URL is invalidated
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
|
||||
const noneSelected = selectedColumns.size === 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export CSV</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which columns to include in the export
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
|
||||
</div>
|
||||
) : exportData ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
{selectedColumns.size} of {exportData.columns.length} columns selected
|
||||
</Label>
|
||||
<Button variant="ghost" size="sm" onClick={toggleAll}>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
|
||||
{exportData.columns.map((col) => (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`col-${col}`}
|
||||
checked={selectedColumns.has(col)}
|
||||
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
|
||||
/>
|
||||
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
|
||||
{formatColumnName(col)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No data available for export.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={isLoading || !exportData || noneSelected}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download CSV
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Converts a camelCase or snake_case column name to Title Case.
|
||||
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
|
||||
*/
|
||||
function formatColumnName(col: string): string {
|
||||
// Replace underscores with spaces
|
||||
let result = col.replace(/_/g, ' ')
|
||||
// Insert space before uppercase letters (camelCase -> spaced)
|
||||
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// Capitalize first letter of each word
|
||||
return result
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
type ExportData = {
|
||||
data: Record<string, unknown>[]
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
type CsvExportDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
exportData: ExportData | undefined
|
||||
isLoading: boolean
|
||||
filename: string
|
||||
onRequestData: () => Promise<ExportData | undefined>
|
||||
}
|
||||
|
||||
export function CsvExportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
exportData,
|
||||
isLoading,
|
||||
filename,
|
||||
onRequestData,
|
||||
}: CsvExportDialogProps) {
|
||||
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
|
||||
const [dataLoaded, setDataLoaded] = useState(false)
|
||||
|
||||
// When dialog opens, fetch data if not already loaded
|
||||
useEffect(() => {
|
||||
if (open && !dataLoaded) {
|
||||
onRequestData().then((result) => {
|
||||
if (result?.columns) {
|
||||
setSelectedColumns(new Set(result.columns))
|
||||
}
|
||||
setDataLoaded(true)
|
||||
})
|
||||
}
|
||||
}, [open, dataLoaded, onRequestData])
|
||||
|
||||
// Sync selected columns when export data changes
|
||||
useEffect(() => {
|
||||
if (exportData?.columns) {
|
||||
setSelectedColumns(new Set(exportData.columns))
|
||||
}
|
||||
}, [exportData])
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDataLoaded(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const toggleColumn = (col: string, checked: boolean) => {
|
||||
const next = new Set(selectedColumns)
|
||||
if (checked) {
|
||||
next.add(col)
|
||||
} else {
|
||||
next.delete(col)
|
||||
}
|
||||
setSelectedColumns(next)
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (!exportData) return
|
||||
if (selectedColumns.size === exportData.columns.length) {
|
||||
setSelectedColumns(new Set())
|
||||
} else {
|
||||
setSelectedColumns(new Set(exportData.columns))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!exportData) return
|
||||
|
||||
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
|
||||
|
||||
// Build CSV header with formatted names
|
||||
const csvHeader = columnsArray.map((col) => {
|
||||
const formatted = formatColumnName(col)
|
||||
// Escape quotes in header
|
||||
if (formatted.includes(',') || formatted.includes('"')) {
|
||||
return `"${formatted.replace(/"/g, '""')}"`
|
||||
}
|
||||
return formatted
|
||||
})
|
||||
|
||||
const csvContent = [
|
||||
csvHeader.join(','),
|
||||
...exportData.data.map((row) =>
|
||||
columnsArray
|
||||
.map((col) => {
|
||||
const value = row[col]
|
||||
if (value === null || value === undefined) return ''
|
||||
const str = String(value)
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// Delay revoking to ensure download starts before URL is invalidated
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
|
||||
const noneSelected = selectedColumns.size === 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export CSV</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which columns to include in the export
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
|
||||
</div>
|
||||
) : exportData ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
{selectedColumns.size} of {exportData.columns.length} columns selected
|
||||
</Label>
|
||||
<Button variant="ghost" size="sm" onClick={toggleAll}>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
|
||||
{exportData.columns.map((col) => (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`col-${col}`}
|
||||
checked={selectedColumns.has(col)}
|
||||
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
|
||||
/>
|
||||
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
|
||||
{formatColumnName(col)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No data available for export.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={isLoading || !exportData || noneSelected}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download CSV
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { MessageSquare, Lock, Send, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Comment {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface DiscussionThreadProps {
|
||||
comments: Comment[]
|
||||
onAddComment?: (content: string) => void
|
||||
isLocked?: boolean
|
||||
maxLength?: number
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMinutes < 1) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function DiscussionThread({
|
||||
comments,
|
||||
onAddComment,
|
||||
isLocked = false,
|
||||
maxLength = 2000,
|
||||
isSubmitting = false,
|
||||
}: DiscussionThreadProps) {
|
||||
const [newComment, setNewComment] = useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = newComment.trim()
|
||||
if (!trimmed || !onAddComment) return
|
||||
onAddComment(trimmed)
|
||||
setNewComment('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Locked banner */}
|
||||
{isLocked && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
Discussion is closed. No new comments can be added.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments list */}
|
||||
{comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<MessageSquare className="h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm font-medium text-muted-foreground">No comments yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Be the first to share your thoughts on this project.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{comment.author}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add comment form */}
|
||||
{!isLocked && onAddComment && (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Add a comment... (Ctrl+Enter to send)"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value.slice(0, maxLength))}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
newComment.length > maxLength * 0.9
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{newComment.length}/{maxLength}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? 'Sending...' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { MessageSquare, Lock, Send, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Comment {
|
||||
id: string
|
||||
author: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface DiscussionThreadProps {
|
||||
comments: Comment[]
|
||||
onAddComment?: (content: string) => void
|
||||
isLocked?: boolean
|
||||
maxLength?: number
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMinutes < 1) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function DiscussionThread({
|
||||
comments,
|
||||
onAddComment,
|
||||
isLocked = false,
|
||||
maxLength = 2000,
|
||||
isSubmitting = false,
|
||||
}: DiscussionThreadProps) {
|
||||
const [newComment, setNewComment] = useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = newComment.trim()
|
||||
if (!trimmed || !onAddComment) return
|
||||
onAddComment(trimmed)
|
||||
setNewComment('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Locked banner */}
|
||||
{isLocked && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
Discussion is closed. No new comments can be added.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments list */}
|
||||
{comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<MessageSquare className="h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm font-medium text-muted-foreground">No comments yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Be the first to share your thoughts on this project.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<Card key={comment.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{comment.author}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add comment form */}
|
||||
{!isLocked && onAddComment && (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Add a comment... (Ctrl+Enter to send)"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value.slice(0, maxLength))}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
newComment.length > maxLength * 0.9
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{newComment.length}/{maxLength}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? 'Sending...' : 'Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
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
|
||||
)}
|
||||
>
|
||||
<div className="rounded-2xl bg-muted/60 p-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground/70" />
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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
|
||||
)}
|
||||
>
|
||||
<div className="rounded-2xl bg-muted/60 p-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground/70" />
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,191 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, type RefObject } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileDown, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
createReportDocument,
|
||||
addCoverPage,
|
||||
addPageBreak,
|
||||
addHeader,
|
||||
addSectionTitle,
|
||||
addStatCards,
|
||||
addTable,
|
||||
addChartImage,
|
||||
addAllPageFooters,
|
||||
savePdf,
|
||||
} from '@/lib/pdf-generator'
|
||||
|
||||
interface ExportPdfButtonProps {
|
||||
stageId: string
|
||||
roundName?: string
|
||||
programName?: string
|
||||
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
}
|
||||
|
||||
export function ExportPdfButton({
|
||||
stageId,
|
||||
roundName,
|
||||
programName,
|
||||
chartRefs,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
}: ExportPdfButtonProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const { refetch } = trpc.export.getReportData.useQuery(
|
||||
{ stageId, sections: [] },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true)
|
||||
toast.info('Generating PDF report...')
|
||||
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (!result.data) {
|
||||
toast.error('Failed to fetch report data')
|
||||
return
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>
|
||||
const rName = roundName || String(data.roundName || 'Report')
|
||||
const pName = programName || String(data.programName || '')
|
||||
|
||||
// 1. Create document
|
||||
const doc = await createReportDocument()
|
||||
|
||||
// 2. Cover page
|
||||
await addCoverPage(doc, {
|
||||
title: 'Round Report',
|
||||
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
|
||||
roundName: rName,
|
||||
programName: pName,
|
||||
})
|
||||
|
||||
// 3. Summary section
|
||||
const summary = data.summary as Record<string, unknown> | undefined
|
||||
if (summary) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Summary', 28)
|
||||
|
||||
y = addStatCards(doc, [
|
||||
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
|
||||
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
|
||||
{
|
||||
label: 'Avg Score',
|
||||
value: summary.averageScore != null
|
||||
? Number(summary.averageScore).toFixed(1)
|
||||
: '--',
|
||||
},
|
||||
{
|
||||
label: 'Completion',
|
||||
value: summary.completionRate != null
|
||||
? `${Number(summary.completionRate).toFixed(0)}%`
|
||||
: '--',
|
||||
},
|
||||
], y)
|
||||
|
||||
// Capture chart images if refs provided
|
||||
if (chartRefs) {
|
||||
for (const [, ref] of Object.entries(chartRefs)) {
|
||||
if (ref.current) {
|
||||
try {
|
||||
y = await addChartImage(doc, ref.current, y, { maxHeight: 90 })
|
||||
} catch {
|
||||
// Skip chart if capture fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Rankings section
|
||||
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
||||
if (rankings && rankings.length > 0) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Project Rankings', 28)
|
||||
|
||||
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
|
||||
const rows = rankings.map((r, i) => [
|
||||
i + 1,
|
||||
String(r.title ?? ''),
|
||||
String(r.teamName ?? ''),
|
||||
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
|
||||
String(r.evaluationCount ?? 0),
|
||||
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
|
||||
])
|
||||
|
||||
y = addTable(doc, headers, rows, y)
|
||||
}
|
||||
|
||||
// 5. Juror stats section
|
||||
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
|
||||
if (jurorStats && jurorStats.length > 0) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Juror Statistics', 28)
|
||||
|
||||
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
|
||||
const rows = jurorStats.map((j) => [
|
||||
String(j.name ?? ''),
|
||||
String(j.assigned ?? 0),
|
||||
String(j.completed ?? 0),
|
||||
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
|
||||
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
|
||||
])
|
||||
|
||||
y = addTable(doc, headers, rows, y)
|
||||
}
|
||||
|
||||
// 6. Criteria breakdown
|
||||
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
|
||||
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
|
||||
|
||||
const headers = ['Criterion', 'Avg Score', 'Responses']
|
||||
const rows = criteriaBreakdown.map((c) => [
|
||||
String(c.label ?? ''),
|
||||
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
|
||||
String(c.count ?? 0),
|
||||
])
|
||||
|
||||
y = addTable(doc, headers, rows, y)
|
||||
}
|
||||
|
||||
// 7. Footer on all pages
|
||||
addAllPageFooters(doc)
|
||||
|
||||
// 8. Save
|
||||
const dateStr = new Date().toISOString().split('T')[0]
|
||||
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
|
||||
|
||||
toast.success('PDF report downloaded successfully')
|
||||
} catch (err) {
|
||||
console.error('PDF generation error:', err)
|
||||
toast.error('Failed to generate PDF report')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [refetch, roundName, programName, chartRefs])
|
||||
|
||||
return (
|
||||
<Button variant={variant} size={size} onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{generating ? 'Generating...' : 'Export PDF Report'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, type RefObject } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileDown, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
createReportDocument,
|
||||
addCoverPage,
|
||||
addPageBreak,
|
||||
addHeader,
|
||||
addSectionTitle,
|
||||
addStatCards,
|
||||
addTable,
|
||||
addChartImage,
|
||||
addAllPageFooters,
|
||||
savePdf,
|
||||
} from '@/lib/pdf-generator'
|
||||
|
||||
interface ExportPdfButtonProps {
|
||||
stageId: string
|
||||
roundName?: string
|
||||
programName?: string
|
||||
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
}
|
||||
|
||||
export function ExportPdfButton({
|
||||
stageId,
|
||||
roundName,
|
||||
programName,
|
||||
chartRefs,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
}: ExportPdfButtonProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const { refetch } = trpc.export.getReportData.useQuery(
|
||||
{ stageId, sections: [] },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true)
|
||||
toast.info('Generating PDF report...')
|
||||
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (!result.data) {
|
||||
toast.error('Failed to fetch report data')
|
||||
return
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, unknown>
|
||||
const rName = roundName || String(data.roundName || 'Report')
|
||||
const pName = programName || String(data.programName || '')
|
||||
|
||||
// 1. Create document
|
||||
const doc = await createReportDocument()
|
||||
|
||||
// 2. Cover page
|
||||
await addCoverPage(doc, {
|
||||
title: 'Round Report',
|
||||
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
|
||||
roundName: rName,
|
||||
programName: pName,
|
||||
})
|
||||
|
||||
// 3. Summary section
|
||||
const summary = data.summary as Record<string, unknown> | undefined
|
||||
if (summary) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Summary', 28)
|
||||
|
||||
y = addStatCards(doc, [
|
||||
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
|
||||
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
|
||||
{
|
||||
label: 'Avg Score',
|
||||
value: summary.averageScore != null
|
||||
? Number(summary.averageScore).toFixed(1)
|
||||
: '--',
|
||||
},
|
||||
{
|
||||
label: 'Completion',
|
||||
value: summary.completionRate != null
|
||||
? `${Number(summary.completionRate).toFixed(0)}%`
|
||||
: '--',
|
||||
},
|
||||
], y)
|
||||
|
||||
// Capture chart images if refs provided
|
||||
if (chartRefs) {
|
||||
for (const [, ref] of Object.entries(chartRefs)) {
|
||||
if (ref.current) {
|
||||
try {
|
||||
y = await addChartImage(doc, ref.current, y, { maxHeight: 90 })
|
||||
} catch {
|
||||
// Skip chart if capture fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Rankings section
|
||||
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
||||
if (rankings && rankings.length > 0) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Project Rankings', 28)
|
||||
|
||||
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
|
||||
const rows = rankings.map((r, i) => [
|
||||
i + 1,
|
||||
String(r.title ?? ''),
|
||||
String(r.teamName ?? ''),
|
||||
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
|
||||
String(r.evaluationCount ?? 0),
|
||||
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
|
||||
])
|
||||
|
||||
y = addTable(doc, headers, rows, y)
|
||||
}
|
||||
|
||||
// 5. Juror stats section
|
||||
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
|
||||
if (jurorStats && jurorStats.length > 0) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Juror Statistics', 28)
|
||||
|
||||
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
|
||||
const rows = jurorStats.map((j) => [
|
||||
String(j.name ?? ''),
|
||||
String(j.assigned ?? 0),
|
||||
String(j.completed ?? 0),
|
||||
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
|
||||
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
|
||||
])
|
||||
|
||||
y = addTable(doc, headers, rows, y)
|
||||
}
|
||||
|
||||
// 6. Criteria breakdown
|
||||
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
|
||||
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
|
||||
addPageBreak(doc)
|
||||
await addHeader(doc, rName)
|
||||
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
|
||||
|
||||
const headers = ['Criterion', 'Avg Score', 'Responses']
|
||||
const rows = criteriaBreakdown.map((c) => [
|
||||
String(c.label ?? ''),
|
||||
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
|
||||
String(c.count ?? 0),
|
||||
])
|
||||
|
||||
y = addTable(doc, headers, rows, y)
|
||||
}
|
||||
|
||||
// 7. Footer on all pages
|
||||
addAllPageFooters(doc)
|
||||
|
||||
// 8. Save
|
||||
const dateStr = new Date().toISOString().split('T')[0]
|
||||
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
|
||||
|
||||
toast.success('PDF report downloaded successfully')
|
||||
} catch (err) {
|
||||
console.error('PDF generation error:', err)
|
||||
toast.error('Failed to generate PDF report')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [refetch, roundName, programName, chartRefs])
|
||||
|
||||
return (
|
||||
<Button variant={variant} size={size} onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{generating ? 'Generating...' : 'Export PDF Report'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Eye, Download, FileText, Image as ImageIcon, Video, File } from 'lucide-react'
|
||||
|
||||
interface FilePreviewProps {
|
||||
fileName: string
|
||||
mimeType: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
function getPreviewType(mimeType: string): 'pdf' | 'image' | 'video' | 'unsupported' {
|
||||
if (mimeType === 'application/pdf') return 'pdf'
|
||||
if (mimeType.startsWith('image/')) return 'image'
|
||||
if (mimeType.startsWith('video/')) return 'video'
|
||||
return 'unsupported'
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (mimeType.startsWith('image/')) return ImageIcon
|
||||
if (mimeType.startsWith('video/')) return Video
|
||||
return File
|
||||
}
|
||||
|
||||
export function FilePreview({ fileName, mimeType, downloadUrl }: FilePreviewProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const previewType = getPreviewType(mimeType)
|
||||
const Icon = getFileIcon(mimeType)
|
||||
const canPreview = previewType !== 'unsupported'
|
||||
|
||||
if (!canPreview) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 truncate">
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{fileName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto">
|
||||
{previewType === 'pdf' && (
|
||||
<iframe
|
||||
src={`${downloadUrl}#toolbar=0`}
|
||||
className="w-full h-[70vh] rounded-md"
|
||||
title={fileName}
|
||||
/>
|
||||
)}
|
||||
{previewType === 'image' && (
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={fileName}
|
||||
className="w-full h-auto max-h-[70vh] object-contain rounded-md"
|
||||
/>
|
||||
)}
|
||||
{previewType === 'video' && (
|
||||
<video
|
||||
src={downloadUrl}
|
||||
controls
|
||||
className="w-full max-h-[70vh] rounded-md"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Eye, Download, FileText, Image as ImageIcon, Video, File } from 'lucide-react'
|
||||
|
||||
interface FilePreviewProps {
|
||||
fileName: string
|
||||
mimeType: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
function getPreviewType(mimeType: string): 'pdf' | 'image' | 'video' | 'unsupported' {
|
||||
if (mimeType === 'application/pdf') return 'pdf'
|
||||
if (mimeType.startsWith('image/')) return 'image'
|
||||
if (mimeType.startsWith('video/')) return 'video'
|
||||
return 'unsupported'
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (mimeType.startsWith('image/')) return ImageIcon
|
||||
if (mimeType.startsWith('video/')) return Video
|
||||
return File
|
||||
}
|
||||
|
||||
export function FilePreview({ fileName, mimeType, downloadUrl }: FilePreviewProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const previewType = getPreviewType(mimeType)
|
||||
const Icon = getFileIcon(mimeType)
|
||||
const canPreview = previewType !== 'unsupported'
|
||||
|
||||
if (!canPreview) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 truncate">
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{fileName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto">
|
||||
{previewType === 'pdf' && (
|
||||
<iframe
|
||||
src={`${downloadUrl}#toolbar=0`}
|
||||
className="w-full h-[70vh] rounded-md"
|
||||
title={fileName}
|
||||
/>
|
||||
)}
|
||||
{previewType === 'image' && (
|
||||
<img
|
||||
src={downloadUrl}
|
||||
alt={fileName}
|
||||
className="w-full h-auto max-h-[70vh] object-contain rounded-md"
|
||||
/>
|
||||
)}
|
||||
{previewType === 'video' && (
|
||||
<video
|
||||
src={downloadUrl}
|
||||
controls
|
||||
className="w-full max-h-[70vh] rounded-md"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,493 +1,493 @@
|
||||
'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
|
||||
stageId?: string
|
||||
availableStages?: Array<{ id: string; name: 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,
|
||||
stageId,
|
||||
availableStages,
|
||||
}: FileUploadProps) {
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
|
||||
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,
|
||||
stageId: selectedStageId ?? undefined,
|
||||
})
|
||||
|
||||
// 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)}>
|
||||
{/* Stage selector */}
|
||||
{availableStages && availableStages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Upload for Stage
|
||||
</label>
|
||||
<Select
|
||||
value={selectedStageId ?? 'null'}
|
||||
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="null">General (no specific stage)</SelectItem>
|
||||
{availableStages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
'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
|
||||
stageId?: string
|
||||
availableStages?: Array<{ id: string; name: 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,
|
||||
stageId,
|
||||
availableStages,
|
||||
}: FileUploadProps) {
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(stageId ?? null)
|
||||
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,
|
||||
stageId: selectedStageId ?? undefined,
|
||||
})
|
||||
|
||||
// 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)}>
|
||||
{/* Stage selector */}
|
||||
{availableStages && availableStages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Upload for Stage
|
||||
</label>
|
||||
<Select
|
||||
value={selectedStageId ?? 'null'}
|
||||
onValueChange={(value) => setSelectedStageId(value === 'null' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="null">General (no specific stage)</SelectItem>
|
||||
{availableStages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LiveScoreAnimationProps {
|
||||
score: number | null
|
||||
maxScore: number
|
||||
label: string
|
||||
animate?: boolean
|
||||
theme?: 'dark' | 'light' | 'branded'
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, maxScore: number): string {
|
||||
const ratio = score / maxScore
|
||||
if (ratio >= 0.75) return 'text-green-500'
|
||||
if (ratio >= 0.5) return 'text-yellow-500'
|
||||
if (ratio >= 0.25) return 'text-orange-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
function getProgressColor(score: number, maxScore: number): string {
|
||||
const ratio = score / maxScore
|
||||
if (ratio >= 0.75) return 'stroke-green-500'
|
||||
if (ratio >= 0.5) return 'stroke-yellow-500'
|
||||
if (ratio >= 0.25) return 'stroke-orange-500'
|
||||
return 'stroke-red-500'
|
||||
}
|
||||
|
||||
function getThemeClasses(theme: 'dark' | 'light' | 'branded') {
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
return {
|
||||
bg: 'bg-gray-900',
|
||||
text: 'text-white',
|
||||
label: 'text-gray-400',
|
||||
ring: 'stroke-gray-700',
|
||||
}
|
||||
case 'light':
|
||||
return {
|
||||
bg: 'bg-white',
|
||||
text: 'text-gray-900',
|
||||
label: 'text-gray-500',
|
||||
ring: 'stroke-gray-200',
|
||||
}
|
||||
case 'branded':
|
||||
return {
|
||||
bg: 'bg-[#053d57]',
|
||||
text: 'text-white',
|
||||
label: 'text-[#557f8c]',
|
||||
ring: 'stroke-[#053d57]/30',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function LiveScoreAnimation({
|
||||
score,
|
||||
maxScore,
|
||||
label,
|
||||
animate = true,
|
||||
theme = 'branded',
|
||||
}: LiveScoreAnimationProps) {
|
||||
const [displayScore, setDisplayScore] = useState(0)
|
||||
const themeClasses = getThemeClasses(theme)
|
||||
const radius = 40
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const targetScore = score ?? 0
|
||||
const progress = maxScore > 0 ? targetScore / maxScore : 0
|
||||
const offset = circumference - progress * circumference
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate || score === null) {
|
||||
setDisplayScore(targetScore)
|
||||
return
|
||||
}
|
||||
|
||||
let frame: number
|
||||
const duration = 1200
|
||||
const startTime = performance.now()
|
||||
const startScore = 0
|
||||
|
||||
const step = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
const current = startScore + (targetScore - startScore) * eased
|
||||
setDisplayScore(Math.round(current * 10) / 10)
|
||||
|
||||
if (progress < 1) {
|
||||
frame = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(step)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [targetScore, animate, score])
|
||||
|
||||
if (score === null) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}>
|
||||
<div className="relative h-24 w-24">
|
||||
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={themeClasses.ring}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={cn('text-lg font-medium', themeClasses.label)}>--</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium', themeClasses.label)}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={animate ? { opacity: 0, scale: 0.8 } : false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}
|
||||
>
|
||||
<div className="relative h-24 w-24">
|
||||
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={themeClasses.ring}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
{/* Progress ring */}
|
||||
<motion.circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={getProgressColor(targetScore, maxScore)}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={animate ? { strokeDashoffset: circumference } : { strokeDashoffset: offset }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl font-bold tabular-nums',
|
||||
themeClasses.text,
|
||||
getScoreColor(targetScore, maxScore)
|
||||
)}
|
||||
>
|
||||
{displayScore.toFixed(maxScore % 1 !== 0 ? 1 : 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium text-center', themeClasses.label)}>{label}</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LiveScoreAnimationProps {
|
||||
score: number | null
|
||||
maxScore: number
|
||||
label: string
|
||||
animate?: boolean
|
||||
theme?: 'dark' | 'light' | 'branded'
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, maxScore: number): string {
|
||||
const ratio = score / maxScore
|
||||
if (ratio >= 0.75) return 'text-green-500'
|
||||
if (ratio >= 0.5) return 'text-yellow-500'
|
||||
if (ratio >= 0.25) return 'text-orange-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
function getProgressColor(score: number, maxScore: number): string {
|
||||
const ratio = score / maxScore
|
||||
if (ratio >= 0.75) return 'stroke-green-500'
|
||||
if (ratio >= 0.5) return 'stroke-yellow-500'
|
||||
if (ratio >= 0.25) return 'stroke-orange-500'
|
||||
return 'stroke-red-500'
|
||||
}
|
||||
|
||||
function getThemeClasses(theme: 'dark' | 'light' | 'branded') {
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
return {
|
||||
bg: 'bg-gray-900',
|
||||
text: 'text-white',
|
||||
label: 'text-gray-400',
|
||||
ring: 'stroke-gray-700',
|
||||
}
|
||||
case 'light':
|
||||
return {
|
||||
bg: 'bg-white',
|
||||
text: 'text-gray-900',
|
||||
label: 'text-gray-500',
|
||||
ring: 'stroke-gray-200',
|
||||
}
|
||||
case 'branded':
|
||||
return {
|
||||
bg: 'bg-[#053d57]',
|
||||
text: 'text-white',
|
||||
label: 'text-[#557f8c]',
|
||||
ring: 'stroke-[#053d57]/30',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function LiveScoreAnimation({
|
||||
score,
|
||||
maxScore,
|
||||
label,
|
||||
animate = true,
|
||||
theme = 'branded',
|
||||
}: LiveScoreAnimationProps) {
|
||||
const [displayScore, setDisplayScore] = useState(0)
|
||||
const themeClasses = getThemeClasses(theme)
|
||||
const radius = 40
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const targetScore = score ?? 0
|
||||
const progress = maxScore > 0 ? targetScore / maxScore : 0
|
||||
const offset = circumference - progress * circumference
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate || score === null) {
|
||||
setDisplayScore(targetScore)
|
||||
return
|
||||
}
|
||||
|
||||
let frame: number
|
||||
const duration = 1200
|
||||
const startTime = performance.now()
|
||||
const startScore = 0
|
||||
|
||||
const step = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// Ease out cubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
const current = startScore + (targetScore - startScore) * eased
|
||||
setDisplayScore(Math.round(current * 10) / 10)
|
||||
|
||||
if (progress < 1) {
|
||||
frame = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(step)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [targetScore, animate, score])
|
||||
|
||||
if (score === null) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}>
|
||||
<div className="relative h-24 w-24">
|
||||
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={themeClasses.ring}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={cn('text-lg font-medium', themeClasses.label)}>--</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium', themeClasses.label)}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={animate ? { opacity: 0, scale: 0.8 } : false}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className={cn('flex flex-col items-center gap-2 rounded-xl p-4', themeClasses.bg)}
|
||||
>
|
||||
<div className="relative h-24 w-24">
|
||||
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
|
||||
{/* Background ring */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={themeClasses.ring}
|
||||
strokeWidth="6"
|
||||
/>
|
||||
{/* Progress ring */}
|
||||
<motion.circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
className={getProgressColor(targetScore, maxScore)}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={animate ? { strokeDashoffset: circumference } : { strokeDashoffset: offset }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl font-bold tabular-nums',
|
||||
themeClasses.text,
|
||||
getScoreColor(targetScore, maxScore)
|
||||
)}
|
||||
>
|
||||
{displayScore.toFixed(maxScore % 1 !== 0 ? 1 : 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium text-center', themeClasses.label)}>{label}</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,335 +1,335 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
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 { Slider } from '@/components/ui/slider'
|
||||
import { ProjectLogo } from './project-logo'
|
||||
import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } 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']
|
||||
|
||||
/**
|
||||
* Crop an image client-side using canvas and return a Blob.
|
||||
*/
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
||||
const image = new Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve()
|
||||
image.onerror = reject
|
||||
image.src = imageSrc
|
||||
})
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('Canvas toBlob failed'))
|
||||
},
|
||||
'image/png',
|
||||
0.9
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function LogoUpload({
|
||||
project,
|
||||
currentLogoUrl,
|
||||
onUploadComplete,
|
||||
children,
|
||||
}: LogoUploadProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | 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 onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedPixels)
|
||||
}, [])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
setImageSrc(ev.target?.result as string)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Crop the image client-side
|
||||
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
|
||||
|
||||
// Get pre-signed upload URL
|
||||
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
|
||||
projectId: project.id,
|
||||
fileName: 'logo.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
|
||||
// Upload cropped blob directly to storage
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: croppedBlob,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
resetState()
|
||||
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 resetState = () => {
|
||||
setImageSrc(null)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setCroppedAreaPixels(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
resetState()
|
||||
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>
|
||||
{imageSrc
|
||||
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
|
||||
: `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">
|
||||
{imageSrc ? (
|
||||
<>
|
||||
{/* Cropper */}
|
||||
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Slider
|
||||
value={[zoom]}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onValueChange={([val]) => setZoom(val)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change image button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetState()
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Choose a different image
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Current logo preview */}
|
||||
<div className="flex justify-center">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={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 && !imageSrc && (
|
||||
<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>
|
||||
{imageSrc && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!croppedAreaPixels || 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>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
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 { Slider } from '@/components/ui/slider'
|
||||
import { ProjectLogo } from './project-logo'
|
||||
import { Upload, Loader2, Trash2, ImagePlus, ZoomIn } 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']
|
||||
|
||||
/**
|
||||
* Crop an image client-side using canvas and return a Blob.
|
||||
*/
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
||||
const image = new Image()
|
||||
image.crossOrigin = 'anonymous'
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve()
|
||||
image.onerror = reject
|
||||
image.src = imageSrc
|
||||
})
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('Canvas toBlob failed'))
|
||||
},
|
||||
'image/png',
|
||||
0.9
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function LogoUpload({
|
||||
project,
|
||||
currentLogoUrl,
|
||||
onUploadComplete,
|
||||
children,
|
||||
}: LogoUploadProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | 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 onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedPixels)
|
||||
}, [])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
setImageSrc(ev.target?.result as string)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Crop the image client-side
|
||||
const croppedBlob = await getCroppedImg(imageSrc, croppedAreaPixels)
|
||||
|
||||
// Get pre-signed upload URL
|
||||
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
|
||||
projectId: project.id,
|
||||
fileName: 'logo.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
|
||||
// Upload cropped blob directly to storage
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: croppedBlob,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
resetState()
|
||||
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 resetState = () => {
|
||||
setImageSrc(null)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setCroppedAreaPixels(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
resetState()
|
||||
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>
|
||||
{imageSrc
|
||||
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
|
||||
: `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">
|
||||
{imageSrc ? (
|
||||
<>
|
||||
{/* Cropper */}
|
||||
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="rect"
|
||||
showGrid
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Slider
|
||||
value={[zoom]}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onValueChange={([val]) => setZoom(val)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change image button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetState()
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Choose a different image
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Current logo preview */}
|
||||
<div className="flex justify-center">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={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 && !imageSrc && (
|
||||
<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>
|
||||
{imageSrc && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!croppedAreaPixels || 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,55 +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 min-w-0">
|
||||
<span className="font-semibold">MOPC</span>
|
||||
{textSuffix && (
|
||||
<span className="text-xs text-muted-foreground truncate">{textSuffix}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 min-w-0">
|
||||
<span className="font-semibold">MOPC</span>
|
||||
{textSuffix && (
|
||||
<span className="text-xs text-muted-foreground truncate">{textSuffix}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,425 +1,425 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Files,
|
||||
Upload,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
CheckCircle,
|
||||
Star,
|
||||
GraduationCap,
|
||||
Vote,
|
||||
Brain,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
CalendarPlus,
|
||||
Heart,
|
||||
BarChart,
|
||||
Award,
|
||||
UserPlus,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
FileCheck,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Info,
|
||||
Calendar,
|
||||
Newspaper,
|
||||
UserX,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
XCircle,
|
||||
Edit,
|
||||
FileUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Icon mapping for notification types
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Files,
|
||||
Upload,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
CheckCircle,
|
||||
Star,
|
||||
GraduationCap,
|
||||
Vote,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
CalendarPlus,
|
||||
Heart,
|
||||
BarChart,
|
||||
Award,
|
||||
UserPlus,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
FileCheck,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Info,
|
||||
Calendar,
|
||||
Newspaper,
|
||||
UserX,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
XCircle,
|
||||
Edit,
|
||||
FileUp,
|
||||
Bell,
|
||||
}
|
||||
|
||||
// Priority styles
|
||||
const PRIORITY_STYLES = {
|
||||
low: {
|
||||
iconBg: 'bg-slate-100 dark:bg-slate-800',
|
||||
iconColor: 'text-slate-500',
|
||||
},
|
||||
normal: {
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
high: {
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
urgent: {
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
type: string
|
||||
priority: string
|
||||
icon: string | null
|
||||
title: string
|
||||
message: string
|
||||
linkUrl: string | null
|
||||
linkLabel: string | null
|
||||
isRead: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onRead,
|
||||
observeRef,
|
||||
}: {
|
||||
notification: Notification
|
||||
onRead: () => void
|
||||
observeRef?: (el: HTMLDivElement | null) => void
|
||||
}) {
|
||||
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
|
||||
const priorityStyle =
|
||||
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
|
||||
PRIORITY_STYLES.normal
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={observeRef}
|
||||
data-notification-id={notification.id}
|
||||
className={cn(
|
||||
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
|
||||
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
|
||||
)}
|
||||
onClick={onRead}
|
||||
>
|
||||
{/* Icon with colored background */}
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
|
||||
priorityStyle.iconBg
|
||||
)}
|
||||
>
|
||||
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
</span>
|
||||
{notification.linkLabel && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{notification.linkLabel} →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unread dot */}
|
||||
{!notification.isRead && (
|
||||
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (notification.linkUrl) {
|
||||
return (
|
||||
<Link href={notification.linkUrl as Route} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
// Derive the role-based path prefix from the current route
|
||||
const pathPrefix = pathname.startsWith('/admin')
|
||||
? '/admin'
|
||||
: pathname.startsWith('/jury')
|
||||
? '/jury'
|
||||
: pathname.startsWith('/mentor')
|
||||
? '/mentor'
|
||||
: pathname.startsWith('/observer')
|
||||
? '/observer'
|
||||
: pathname.startsWith('/applicant')
|
||||
? '/applicant'
|
||||
: ''
|
||||
|
||||
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
|
||||
{
|
||||
unreadOnly: false,
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
|
||||
}
|
||||
)
|
||||
|
||||
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const unreadCount = countData ?? 0
|
||||
const notifications = notificationData?.notifications ?? []
|
||||
|
||||
// Track unread notification IDs that have become visible
|
||||
const pendingReadIds = useRef<Set<string>>(new Set())
|
||||
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
// Flush pending read IDs in a batch
|
||||
const flushPendingReads = useCallback(() => {
|
||||
if (pendingReadIds.current.size === 0) return
|
||||
const ids = Array.from(pendingReadIds.current)
|
||||
pendingReadIds.current.clear()
|
||||
markBatchAsReadMutation.mutate({ ids })
|
||||
}, [markBatchAsReadMutation])
|
||||
|
||||
// Set up IntersectionObserver when popover opens
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Flush any remaining on close
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushPendingReads()
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const id = (entry.target as HTMLElement).dataset.notificationId
|
||||
if (id) {
|
||||
// Check if this notification is unread
|
||||
const notif = notifications.find((n) => n.id === id)
|
||||
if (notif && !notif.isRead) {
|
||||
pendingReadIds.current.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Debounce the batch call
|
||||
if (pendingReadIds.current.size > 0) {
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushTimer.current = setTimeout(flushPendingReads, 500)
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
)
|
||||
|
||||
// Observe all currently tracked items
|
||||
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, notifications])
|
||||
|
||||
// Ref callback for each notification item
|
||||
const getItemRef = useCallback(
|
||||
(id: string) => (el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
itemRefs.current.set(id, el)
|
||||
observerRef.current?.observe(el)
|
||||
} else {
|
||||
const prev = itemRefs.current.get(id)
|
||||
if (prev) observerRef.current?.unobserve(prev)
|
||||
itemRefs.current.delete(id)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell
|
||||
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
|
||||
/>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
|
||||
hasUrgent ? 'bg-red-500' : 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<h3 className="font-semibold">Notifications</h3>
|
||||
<div className="flex gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`${pathPrefix}/settings` as Route}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="sr-only">Notification settings</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
|
||||
onRead={() => {
|
||||
if (!notification.isRead) {
|
||||
markAsReadMutation.mutate({ id: notification.id })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{notifications.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
No notifications yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="p-2 border-t bg-muted/30">
|
||||
<Button variant="ghost" className="w-full" asChild>
|
||||
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Files,
|
||||
Upload,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
CheckCircle,
|
||||
Star,
|
||||
GraduationCap,
|
||||
Vote,
|
||||
Brain,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
CalendarPlus,
|
||||
Heart,
|
||||
BarChart,
|
||||
Award,
|
||||
UserPlus,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
FileCheck,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Info,
|
||||
Calendar,
|
||||
Newspaper,
|
||||
UserX,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
XCircle,
|
||||
Edit,
|
||||
FileUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Icon mapping for notification types
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Files,
|
||||
Upload,
|
||||
ClipboardList,
|
||||
PlayCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Lock,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
CheckCircle,
|
||||
Star,
|
||||
GraduationCap,
|
||||
Vote,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
CalendarPlus,
|
||||
Heart,
|
||||
BarChart,
|
||||
Award,
|
||||
UserPlus,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
FileCheck,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Info,
|
||||
Calendar,
|
||||
Newspaper,
|
||||
UserX,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
XCircle,
|
||||
Edit,
|
||||
FileUp,
|
||||
Bell,
|
||||
}
|
||||
|
||||
// Priority styles
|
||||
const PRIORITY_STYLES = {
|
||||
low: {
|
||||
iconBg: 'bg-slate-100 dark:bg-slate-800',
|
||||
iconColor: 'text-slate-500',
|
||||
},
|
||||
normal: {
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
high: {
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
urgent: {
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
type Notification = {
|
||||
id: string
|
||||
type: string
|
||||
priority: string
|
||||
icon: string | null
|
||||
title: string
|
||||
message: string
|
||||
linkUrl: string | null
|
||||
linkLabel: string | null
|
||||
isRead: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onRead,
|
||||
observeRef,
|
||||
}: {
|
||||
notification: Notification
|
||||
onRead: () => void
|
||||
observeRef?: (el: HTMLDivElement | null) => void
|
||||
}) {
|
||||
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
|
||||
const priorityStyle =
|
||||
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
|
||||
PRIORITY_STYLES.normal
|
||||
|
||||
const content = (
|
||||
<div
|
||||
ref={observeRef}
|
||||
data-notification-id={notification.id}
|
||||
className={cn(
|
||||
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
|
||||
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
|
||||
)}
|
||||
onClick={onRead}
|
||||
>
|
||||
{/* Icon with colored background */}
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
|
||||
priorityStyle.iconBg
|
||||
)}
|
||||
>
|
||||
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
</span>
|
||||
{notification.linkLabel && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{notification.linkLabel} →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unread dot */}
|
||||
{!notification.isRead && (
|
||||
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (notification.linkUrl) {
|
||||
return (
|
||||
<Link href={notification.linkUrl as Route} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
||||
// Derive the role-based path prefix from the current route
|
||||
const pathPrefix = pathname.startsWith('/admin')
|
||||
? '/admin'
|
||||
: pathname.startsWith('/jury')
|
||||
? '/jury'
|
||||
: pathname.startsWith('/mentor')
|
||||
? '/mentor'
|
||||
: pathname.startsWith('/observer')
|
||||
? '/observer'
|
||||
: pathname.startsWith('/applicant')
|
||||
? '/applicant'
|
||||
: ''
|
||||
|
||||
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
}
|
||||
)
|
||||
|
||||
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
|
||||
{
|
||||
unreadOnly: false,
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
enabled: open && isAuthenticated, // Only fetch when popover is open and authenticated
|
||||
}
|
||||
)
|
||||
|
||||
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const markBatchAsReadMutation = trpc.notification.markBatchAsRead.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const unreadCount = countData ?? 0
|
||||
const notifications = notificationData?.notifications ?? []
|
||||
|
||||
// Track unread notification IDs that have become visible
|
||||
const pendingReadIds = useRef<Set<string>>(new Set())
|
||||
const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
// Flush pending read IDs in a batch
|
||||
const flushPendingReads = useCallback(() => {
|
||||
if (pendingReadIds.current.size === 0) return
|
||||
const ids = Array.from(pendingReadIds.current)
|
||||
pendingReadIds.current.clear()
|
||||
markBatchAsReadMutation.mutate({ ids })
|
||||
}, [markBatchAsReadMutation])
|
||||
|
||||
// Set up IntersectionObserver when popover opens
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Flush any remaining on close
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushPendingReads()
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const id = (entry.target as HTMLElement).dataset.notificationId
|
||||
if (id) {
|
||||
// Check if this notification is unread
|
||||
const notif = notifications.find((n) => n.id === id)
|
||||
if (notif && !notif.isRead) {
|
||||
pendingReadIds.current.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Debounce the batch call
|
||||
if (pendingReadIds.current.size > 0) {
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushTimer.current = setTimeout(flushPendingReads, 500)
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
)
|
||||
|
||||
// Observe all currently tracked items
|
||||
itemRefs.current.forEach((el) => observerRef.current?.observe(el))
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, notifications])
|
||||
|
||||
// Ref callback for each notification item
|
||||
const getItemRef = useCallback(
|
||||
(id: string) => (el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
itemRefs.current.set(id, el)
|
||||
observerRef.current?.observe(el)
|
||||
} else {
|
||||
const prev = itemRefs.current.get(id)
|
||||
if (prev) observerRef.current?.unobserve(prev)
|
||||
itemRefs.current.delete(id)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell
|
||||
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
|
||||
/>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
|
||||
hasUrgent ? 'bg-red-500' : 'bg-primary'
|
||||
)}
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<h3 className="font-semibold">Notifications</h3>
|
||||
<div className="flex gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`${pathPrefix}/settings` as Route}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="sr-only">Notification settings</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
observeRef={!notification.isRead ? getItemRef(notification.id) : undefined}
|
||||
onRead={() => {
|
||||
if (!notification.isRead) {
|
||||
markAsReadMutation.mutate({ id: notification.id })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{notifications.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
No notifications yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="p-2 border-t bg-muted/30">
|
||||
<Button variant="ghost" className="w-full" asChild>
|
||||
<Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
perPage: number
|
||||
onPageChange: (page: number) => void
|
||||
onPerPageChange?: (perPage: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
perPage,
|
||||
onPageChange,
|
||||
onPerPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1 && !onPerPageChange) return null
|
||||
|
||||
const from = (page - 1) * perPage + 1
|
||||
const to = Math.min(page * perPage, total)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {from} to {to} of {total} results
|
||||
</p>
|
||||
{onPerPageChange && (
|
||||
<Select
|
||||
value={String(perPage)}
|
||||
onValueChange={(v) => onPerPageChange(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 50, 100].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
perPage: number
|
||||
onPageChange: (page: number) => void
|
||||
onPerPageChange?: (perPage: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
perPage,
|
||||
onPageChange,
|
||||
onPerPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1 && !onPerPageChange) return null
|
||||
|
||||
const from = (page - 1) * perPage + 1
|
||||
const to = Math.min(page * perPage, total)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {from} to {to} of {total} results
|
||||
</p>
|
||||
{onPerPageChange && (
|
||||
<Select
|
||||
value={String(perPage)}
|
||||
onValueChange={(v) => onPerPageChange(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 50, 100].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Copy, QrCode } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface QRCodeDisplayProps {
|
||||
url: string
|
||||
title?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple QR code using Canvas API.
|
||||
* Uses a basic QR encoding approach for URLs.
|
||||
*/
|
||||
function generateQRMatrix(data: string): boolean[][] {
|
||||
// Simple QR-like grid pattern based on data hash
|
||||
// For production, use a library like 'qrcode', but this is a lightweight visual
|
||||
const size = 25
|
||||
const matrix: boolean[][] = Array.from({ length: size }, () =>
|
||||
Array(size).fill(false)
|
||||
)
|
||||
|
||||
// Add finder patterns (top-left, top-right, bottom-left)
|
||||
const addFinderPattern = (row: number, col: number) => {
|
||||
for (let r = 0; r < 7; r++) {
|
||||
for (let c = 0; c < 7; c++) {
|
||||
if (
|
||||
r === 0 || r === 6 || c === 0 || c === 6 || // outer border
|
||||
(r >= 2 && r <= 4 && c >= 2 && c <= 4) // inner block
|
||||
) {
|
||||
if (row + r < size && col + c < size) {
|
||||
matrix[row + r][col + c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addFinderPattern(0, 0)
|
||||
addFinderPattern(0, size - 7)
|
||||
addFinderPattern(size - 7, 0)
|
||||
|
||||
// Fill data area with a hash-based pattern
|
||||
let hash = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0
|
||||
}
|
||||
|
||||
for (let r = 8; r < size - 8; r++) {
|
||||
for (let c = 8; c < size - 8; c++) {
|
||||
hash = ((hash << 5) - hash + r * size + c) | 0
|
||||
matrix[r][c] = (hash & 1) === 1
|
||||
}
|
||||
}
|
||||
|
||||
// Timing patterns
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
matrix[6][i] = i % 2 === 0
|
||||
matrix[i][6] = i % 2 === 0
|
||||
}
|
||||
|
||||
return matrix
|
||||
}
|
||||
|
||||
function drawQR(canvas: HTMLCanvasElement, data: string, pixelSize: number) {
|
||||
const matrix = generateQRMatrix(data)
|
||||
const size = matrix.length
|
||||
const totalSize = size * pixelSize
|
||||
canvas.width = totalSize
|
||||
canvas.height = totalSize
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, totalSize, totalSize)
|
||||
|
||||
// Draw modules
|
||||
ctx.fillStyle = '#053d57'
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (matrix[r][c]) {
|
||||
ctx.fillRect(c * pixelSize, r * pixelSize, pixelSize, pixelSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function QRCodeDisplay({ url, title = 'Scan to Vote', size = 200 }: QRCodeDisplayProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const pixelSize = Math.floor(size / 25)
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
drawQR(canvasRef.current, url, pixelSize)
|
||||
}
|
||||
}, [url, pixelSize])
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success('URL copied to clipboard')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<QrCode className="h-4 w-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-3">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="border rounded-lg"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<code className="flex-1 text-xs bg-muted p-2 rounded truncate">
|
||||
{url}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopyUrl}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Copy, QrCode } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface QRCodeDisplayProps {
|
||||
url: string
|
||||
title?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple QR code using Canvas API.
|
||||
* Uses a basic QR encoding approach for URLs.
|
||||
*/
|
||||
function generateQRMatrix(data: string): boolean[][] {
|
||||
// Simple QR-like grid pattern based on data hash
|
||||
// For production, use a library like 'qrcode', but this is a lightweight visual
|
||||
const size = 25
|
||||
const matrix: boolean[][] = Array.from({ length: size }, () =>
|
||||
Array(size).fill(false)
|
||||
)
|
||||
|
||||
// Add finder patterns (top-left, top-right, bottom-left)
|
||||
const addFinderPattern = (row: number, col: number) => {
|
||||
for (let r = 0; r < 7; r++) {
|
||||
for (let c = 0; c < 7; c++) {
|
||||
if (
|
||||
r === 0 || r === 6 || c === 0 || c === 6 || // outer border
|
||||
(r >= 2 && r <= 4 && c >= 2 && c <= 4) // inner block
|
||||
) {
|
||||
if (row + r < size && col + c < size) {
|
||||
matrix[row + r][col + c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addFinderPattern(0, 0)
|
||||
addFinderPattern(0, size - 7)
|
||||
addFinderPattern(size - 7, 0)
|
||||
|
||||
// Fill data area with a hash-based pattern
|
||||
let hash = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0
|
||||
}
|
||||
|
||||
for (let r = 8; r < size - 8; r++) {
|
||||
for (let c = 8; c < size - 8; c++) {
|
||||
hash = ((hash << 5) - hash + r * size + c) | 0
|
||||
matrix[r][c] = (hash & 1) === 1
|
||||
}
|
||||
}
|
||||
|
||||
// Timing patterns
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
matrix[6][i] = i % 2 === 0
|
||||
matrix[i][6] = i % 2 === 0
|
||||
}
|
||||
|
||||
return matrix
|
||||
}
|
||||
|
||||
function drawQR(canvas: HTMLCanvasElement, data: string, pixelSize: number) {
|
||||
const matrix = generateQRMatrix(data)
|
||||
const size = matrix.length
|
||||
const totalSize = size * pixelSize
|
||||
canvas.width = totalSize
|
||||
canvas.height = totalSize
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, totalSize, totalSize)
|
||||
|
||||
// Draw modules
|
||||
ctx.fillStyle = '#053d57'
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (matrix[r][c]) {
|
||||
ctx.fillRect(c * pixelSize, r * pixelSize, pixelSize, pixelSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function QRCodeDisplay({ url, title = 'Scan to Vote', size = 200 }: QRCodeDisplayProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const pixelSize = Math.floor(size / 25)
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
drawQR(canvasRef.current, url, pixelSize)
|
||||
}
|
||||
}, [url, pixelSize])
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success('URL copied to clipboard')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<QrCode className="h-4 w-4" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-3">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="border rounded-lg"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<code className="flex-1 text-xs bg-muted p-2 rounded truncate">
|
||||
{url}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopyUrl}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,364 +1,364 @@
|
||||
'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 {
|
||||
Upload,
|
||||
FileIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatFileSize } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function getMimeLabel(mime: string): string {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
if (mime.startsWith('image/')) return 'Images'
|
||||
if (mime.startsWith('video/')) return 'Video'
|
||||
if (mime.includes('wordprocessingml')) return 'Word'
|
||||
if (mime.includes('spreadsheetml')) return 'Excel'
|
||||
if (mime.includes('presentationml')) return 'PowerPoint'
|
||||
if (mime.endsWith('/*')) return mime.replace('/*', '')
|
||||
return mime
|
||||
}
|
||||
|
||||
interface FileRequirement {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
acceptedMimeTypes: string[]
|
||||
maxSizeMB?: number | null
|
||||
isRequired: boolean
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string | Date
|
||||
requirementId?: string | null
|
||||
}
|
||||
|
||||
interface RequirementUploadSlotProps {
|
||||
requirement: FileRequirement
|
||||
existingFile?: UploadedFile | null
|
||||
projectId: string
|
||||
stageId: string
|
||||
onFileChange?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RequirementUploadSlot({
|
||||
requirement,
|
||||
existingFile,
|
||||
projectId,
|
||||
stageId,
|
||||
onFileChange,
|
||||
disabled = false,
|
||||
}: RequirementUploadSlotProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
|
||||
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
|
||||
const deleteFile = trpc.applicant.deleteFile.useMutation()
|
||||
|
||||
const acceptsMime = useCallback(
|
||||
(mimeType: string) => {
|
||||
if (requirement.acceptedMimeTypes.length === 0) return true
|
||||
return requirement.acceptedMimeTypes.some((pattern) => {
|
||||
if (pattern.endsWith('/*')) {
|
||||
return mimeType.startsWith(pattern.replace('/*', '/'))
|
||||
}
|
||||
return mimeType === pattern
|
||||
})
|
||||
},
|
||||
[requirement.acceptedMimeTypes]
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
// Validate mime type
|
||||
if (!acceptsMime(file.type)) {
|
||||
toast.error(`File type ${file.type} is not accepted for this requirement`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
|
||||
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
// Get presigned URL
|
||||
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
|
||||
await getUploadUrl.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
fileType: 'OTHER',
|
||||
stageId,
|
||||
requirementId: requirement.id,
|
||||
})
|
||||
|
||||
// Upload file with progress tracking
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
setProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
})
|
||||
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('Upload failed')))
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
// Save metadata
|
||||
await saveFileMetadata.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
fileType: 'OTHER',
|
||||
bucket,
|
||||
objectKey,
|
||||
stageId: uploadStageId || stageId,
|
||||
isLate: isLate || false,
|
||||
requirementId: requirement.id,
|
||||
})
|
||||
|
||||
toast.success(`${requirement.name} uploaded successfully`)
|
||||
onFileChange?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
}
|
||||
},
|
||||
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!existingFile) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteFile.mutateAsync({ fileId: existingFile.id })
|
||||
toast.success('File deleted')
|
||||
onFileChange?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [existingFile, deleteFile, onFileChange])
|
||||
|
||||
const isFulfilled = !!existingFile
|
||||
const statusColor = isFulfilled
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
|
||||
: requirement.isRequired
|
||||
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
|
||||
: 'border-muted'
|
||||
|
||||
// Build accept string for file input
|
||||
const acceptStr =
|
||||
requirement.acceptedMimeTypes.length > 0
|
||||
? requirement.acceptedMimeTypes.join(',')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isFulfilled ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : requirement.isRequired ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{requirement.name}</span>
|
||||
<Badge
|
||||
variant={requirement.isRequired ? 'destructive' : 'secondary'}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{requirement.isRequired ? 'Required' : 'Optional'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{requirement.description && (
|
||||
<p className="text-xs text-muted-foreground ml-6 mb-2">
|
||||
{requirement.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1 ml-6 mb-2">
|
||||
{requirement.acceptedMimeTypes.map((mime) => (
|
||||
<Badge key={mime} variant="outline" className="text-xs">
|
||||
{getMimeLabel(mime)}
|
||||
</Badge>
|
||||
))}
|
||||
{requirement.maxSizeMB && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Max {requirement.maxSizeMB}MB
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{existingFile && (
|
||||
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileIcon className="h-3 w-3" />
|
||||
<span className="truncate">{existingFile.fileName}</span>
|
||||
<span>({formatFileSize(existingFile.size)})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="ml-6 mt-2">
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{existingFile ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={acceptStr}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RequirementUploadListProps {
|
||||
projectId: string
|
||||
stageId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
|
||||
stageId,
|
||||
})
|
||||
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
|
||||
|
||||
if (requirements.length === 0) return null
|
||||
|
||||
const handleFileChange = () => {
|
||||
utils.file.listByProject.invalidate({ projectId, stageId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Required Documents
|
||||
</h3>
|
||||
{requirements.map((req) => {
|
||||
const existing = files.find(
|
||||
(f) => (f as { requirementId?: string | null }).requirementId === req.id
|
||||
)
|
||||
return (
|
||||
<RequirementUploadSlot
|
||||
key={req.id}
|
||||
requirement={req}
|
||||
existingFile={
|
||||
existing
|
||||
? {
|
||||
id: existing.id,
|
||||
fileName: existing.fileName,
|
||||
mimeType: existing.mimeType,
|
||||
size: existing.size,
|
||||
createdAt: existing.createdAt,
|
||||
requirementId: (existing as { requirementId?: string | null }).requirementId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
projectId={projectId}
|
||||
stageId={stageId}
|
||||
onFileChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'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 {
|
||||
Upload,
|
||||
FileIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatFileSize } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function getMimeLabel(mime: string): string {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
if (mime.startsWith('image/')) return 'Images'
|
||||
if (mime.startsWith('video/')) return 'Video'
|
||||
if (mime.includes('wordprocessingml')) return 'Word'
|
||||
if (mime.includes('spreadsheetml')) return 'Excel'
|
||||
if (mime.includes('presentationml')) return 'PowerPoint'
|
||||
if (mime.endsWith('/*')) return mime.replace('/*', '')
|
||||
return mime
|
||||
}
|
||||
|
||||
interface FileRequirement {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
acceptedMimeTypes: string[]
|
||||
maxSizeMB?: number | null
|
||||
isRequired: boolean
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string | Date
|
||||
requirementId?: string | null
|
||||
}
|
||||
|
||||
interface RequirementUploadSlotProps {
|
||||
requirement: FileRequirement
|
||||
existingFile?: UploadedFile | null
|
||||
projectId: string
|
||||
stageId: string
|
||||
onFileChange?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RequirementUploadSlot({
|
||||
requirement,
|
||||
existingFile,
|
||||
projectId,
|
||||
stageId,
|
||||
onFileChange,
|
||||
disabled = false,
|
||||
}: RequirementUploadSlotProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
|
||||
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
|
||||
const deleteFile = trpc.applicant.deleteFile.useMutation()
|
||||
|
||||
const acceptsMime = useCallback(
|
||||
(mimeType: string) => {
|
||||
if (requirement.acceptedMimeTypes.length === 0) return true
|
||||
return requirement.acceptedMimeTypes.some((pattern) => {
|
||||
if (pattern.endsWith('/*')) {
|
||||
return mimeType.startsWith(pattern.replace('/*', '/'))
|
||||
}
|
||||
return mimeType === pattern
|
||||
})
|
||||
},
|
||||
[requirement.acceptedMimeTypes]
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
// Validate mime type
|
||||
if (!acceptsMime(file.type)) {
|
||||
toast.error(`File type ${file.type} is not accepted for this requirement`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
|
||||
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
// Get presigned URL
|
||||
const { url, bucket, objectKey, isLate, stageId: uploadStageId } =
|
||||
await getUploadUrl.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
fileType: 'OTHER',
|
||||
stageId,
|
||||
requirementId: requirement.id,
|
||||
})
|
||||
|
||||
// Upload file with progress tracking
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
setProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
})
|
||||
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('Upload failed')))
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
// Save metadata
|
||||
await saveFileMetadata.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
fileType: 'OTHER',
|
||||
bucket,
|
||||
objectKey,
|
||||
stageId: uploadStageId || stageId,
|
||||
isLate: isLate || false,
|
||||
requirementId: requirement.id,
|
||||
})
|
||||
|
||||
toast.success(`${requirement.name} uploaded successfully`)
|
||||
onFileChange?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
}
|
||||
},
|
||||
[projectId, stageId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!existingFile) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteFile.mutateAsync({ fileId: existingFile.id })
|
||||
toast.success('File deleted')
|
||||
onFileChange?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [existingFile, deleteFile, onFileChange])
|
||||
|
||||
const isFulfilled = !!existingFile
|
||||
const statusColor = isFulfilled
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
|
||||
: requirement.isRequired
|
||||
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
|
||||
: 'border-muted'
|
||||
|
||||
// Build accept string for file input
|
||||
const acceptStr =
|
||||
requirement.acceptedMimeTypes.length > 0
|
||||
? requirement.acceptedMimeTypes.join(',')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isFulfilled ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : requirement.isRequired ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{requirement.name}</span>
|
||||
<Badge
|
||||
variant={requirement.isRequired ? 'destructive' : 'secondary'}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{requirement.isRequired ? 'Required' : 'Optional'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{requirement.description && (
|
||||
<p className="text-xs text-muted-foreground ml-6 mb-2">
|
||||
{requirement.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1 ml-6 mb-2">
|
||||
{requirement.acceptedMimeTypes.map((mime) => (
|
||||
<Badge key={mime} variant="outline" className="text-xs">
|
||||
{getMimeLabel(mime)}
|
||||
</Badge>
|
||||
))}
|
||||
{requirement.maxSizeMB && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Max {requirement.maxSizeMB}MB
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{existingFile && (
|
||||
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileIcon className="h-3 w-3" />
|
||||
<span className="truncate">{existingFile.fileName}</span>
|
||||
<span>({formatFileSize(existingFile.size)})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="ml-6 mt-2">
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{existingFile ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={acceptStr}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RequirementUploadListProps {
|
||||
projectId: string
|
||||
stageId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RequirementUploadList({ projectId, stageId, disabled }: RequirementUploadListProps) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
|
||||
stageId,
|
||||
})
|
||||
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, stageId })
|
||||
|
||||
if (requirements.length === 0) return null
|
||||
|
||||
const handleFileChange = () => {
|
||||
utils.file.listByProject.invalidate({ projectId, stageId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Required Documents
|
||||
</h3>
|
||||
{requirements.map((req) => {
|
||||
const existing = files.find(
|
||||
(f) => (f as { requirementId?: string | null }).requirementId === req.id
|
||||
)
|
||||
return (
|
||||
<RequirementUploadSlot
|
||||
key={req.id}
|
||||
requirement={req}
|
||||
existingFile={
|
||||
existing
|
||||
? {
|
||||
id: existing.id,
|
||||
fileName: existing.fileName,
|
||||
mimeType: existing.mimeType,
|
||||
size: existing.size,
|
||||
createdAt: existing.createdAt,
|
||||
requirementId: (existing as { requirementId?: string | null }).requirementId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
projectId={projectId}
|
||||
stageId={stageId}
|
||||
onFileChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StageBreadcrumbProps {
|
||||
pipelineName: string
|
||||
trackName: string
|
||||
stageName: string
|
||||
stageId?: string
|
||||
pipelineId?: string
|
||||
className?: string
|
||||
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
|
||||
}
|
||||
|
||||
export function StageBreadcrumb({
|
||||
pipelineName,
|
||||
trackName,
|
||||
stageName,
|
||||
stageId,
|
||||
pipelineId,
|
||||
className,
|
||||
basePath = '/jury/stages',
|
||||
}: StageBreadcrumbProps) {
|
||||
return (
|
||||
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
|
||||
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
|
||||
{pipelineName}
|
||||
</Link>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{trackName}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
{stageId ? (
|
||||
<Link
|
||||
href={`${basePath}/${stageId}/assignments` as Route}
|
||||
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
|
||||
>
|
||||
{stageName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StageBreadcrumbProps {
|
||||
pipelineName: string
|
||||
trackName: string
|
||||
stageName: string
|
||||
stageId?: string
|
||||
pipelineId?: string
|
||||
className?: string
|
||||
basePath?: string // e.g. '/jury/stages' or '/admin/reports/stages'
|
||||
}
|
||||
|
||||
export function StageBreadcrumb({
|
||||
pipelineName,
|
||||
trackName,
|
||||
stageName,
|
||||
stageId,
|
||||
pipelineId,
|
||||
className,
|
||||
basePath = '/jury/stages',
|
||||
}: StageBreadcrumbProps) {
|
||||
return (
|
||||
<nav className={cn('flex items-center gap-1 text-sm text-muted-foreground', className)}>
|
||||
<Link href={basePath as Route} className="hover:text-foreground transition-colors truncate max-w-[150px]">
|
||||
{pipelineName}
|
||||
</Link>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{trackName}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
{stageId ? (
|
||||
<Link
|
||||
href={`${basePath}/${stageId}/assignments` as Route}
|
||||
className="hover:text-foreground transition-colors font-medium text-foreground truncate max-w-[150px]"
|
||||
>
|
||||
{stageName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-foreground truncate max-w-[150px]">{stageName}</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,205 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
XCircle,
|
||||
FileText,
|
||||
Users,
|
||||
Vote,
|
||||
ArrowRightLeft,
|
||||
Presentation,
|
||||
Award,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface StageTimelineItem {
|
||||
id: string
|
||||
name: string
|
||||
stageType: string
|
||||
isCurrent: boolean
|
||||
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
|
||||
enteredAt?: Date | string | null
|
||||
}
|
||||
|
||||
interface StageTimelineProps {
|
||||
stages: StageTimelineItem[]
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const stageTypeIcons: Record<string, typeof Circle> = {
|
||||
INTAKE: FileText,
|
||||
EVALUATION: Users,
|
||||
VOTING: Vote,
|
||||
DELIBERATION: ArrowRightLeft,
|
||||
LIVE_PRESENTATION: Presentation,
|
||||
AWARD: Award,
|
||||
}
|
||||
|
||||
function getStateColor(state: string, isCurrent: boolean) {
|
||||
if (state === 'REJECTED' || state === 'ELIMINATED')
|
||||
return 'bg-destructive text-destructive-foreground'
|
||||
if (state === 'PASSED' || state === 'COMPLETED')
|
||||
return 'bg-green-600 text-white dark:bg-green-700'
|
||||
if (state === 'IN_PROGRESS' || isCurrent)
|
||||
return 'bg-primary text-primary-foreground'
|
||||
return 'border-2 border-muted bg-background text-muted-foreground'
|
||||
}
|
||||
|
||||
function getConnectorColor(state: string) {
|
||||
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
|
||||
return 'bg-primary'
|
||||
if (state === 'REJECTED' || state === 'ELIMINATED')
|
||||
return 'bg-destructive/30'
|
||||
return 'bg-muted'
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string | null | undefined) {
|
||||
if (!date) return null
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function StageTimeline({
|
||||
stages,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
}: StageTimelineProps) {
|
||||
if (stages.length === 0) return null
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className="space-y-0">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stageTypeIcons[stage.stageType] || Circle
|
||||
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
|
||||
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
|
||||
const isPending = !isPassed && !isRejected && !stage.isCurrent
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="relative flex gap-4">
|
||||
{index < stages.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-[15px] top-[32px] h-full w-0.5',
|
||||
getConnectorColor(stage.state)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full',
|
||||
getStateColor(stage.state, stage.isCurrent)
|
||||
)}
|
||||
>
|
||||
{isRejected ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : isPassed ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : stage.isCurrent ? (
|
||||
<Clock className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
isRejected && 'text-destructive',
|
||||
isPending && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{stage.name}
|
||||
</p>
|
||||
{stage.isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
{stage.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(stage.enteredAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal orientation
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stageTypeIcons[stage.stageType] || Circle
|
||||
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
|
||||
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
|
||||
const isPending = !isPassed && !isRejected && !stage.isCurrent
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-8 lg:w-12 shrink-0',
|
||||
getConnectorColor(stages[index - 1].state)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
|
||||
getStateColor(stage.state, stage.isCurrent)
|
||||
)}
|
||||
>
|
||||
{isRejected ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : isPassed ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : stage.isCurrent ? (
|
||||
<Clock className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center max-w-[80px]">
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-medium leading-tight',
|
||||
isRejected && 'text-destructive',
|
||||
isPending && 'text-muted-foreground',
|
||||
stage.isCurrent && 'text-primary'
|
||||
)}
|
||||
>
|
||||
{stage.name}
|
||||
</p>
|
||||
{stage.enteredAt && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatDate(stage.enteredAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
XCircle,
|
||||
FileText,
|
||||
Users,
|
||||
Vote,
|
||||
ArrowRightLeft,
|
||||
Presentation,
|
||||
Award,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface StageTimelineItem {
|
||||
id: string
|
||||
name: string
|
||||
stageType: string
|
||||
isCurrent: boolean
|
||||
state: string // PENDING, IN_PROGRESS, PASSED, REJECTED, etc.
|
||||
enteredAt?: Date | string | null
|
||||
}
|
||||
|
||||
interface StageTimelineProps {
|
||||
stages: StageTimelineItem[]
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const stageTypeIcons: Record<string, typeof Circle> = {
|
||||
INTAKE: FileText,
|
||||
EVALUATION: Users,
|
||||
VOTING: Vote,
|
||||
DELIBERATION: ArrowRightLeft,
|
||||
LIVE_PRESENTATION: Presentation,
|
||||
AWARD: Award,
|
||||
}
|
||||
|
||||
function getStateColor(state: string, isCurrent: boolean) {
|
||||
if (state === 'REJECTED' || state === 'ELIMINATED')
|
||||
return 'bg-destructive text-destructive-foreground'
|
||||
if (state === 'PASSED' || state === 'COMPLETED')
|
||||
return 'bg-green-600 text-white dark:bg-green-700'
|
||||
if (state === 'IN_PROGRESS' || isCurrent)
|
||||
return 'bg-primary text-primary-foreground'
|
||||
return 'border-2 border-muted bg-background text-muted-foreground'
|
||||
}
|
||||
|
||||
function getConnectorColor(state: string) {
|
||||
if (state === 'PASSED' || state === 'COMPLETED' || state === 'IN_PROGRESS')
|
||||
return 'bg-primary'
|
||||
if (state === 'REJECTED' || state === 'ELIMINATED')
|
||||
return 'bg-destructive/30'
|
||||
return 'bg-muted'
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string | null | undefined) {
|
||||
if (!date) return null
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function StageTimeline({
|
||||
stages,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
}: StageTimelineProps) {
|
||||
if (stages.length === 0) return null
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className="space-y-0">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stageTypeIcons[stage.stageType] || Circle
|
||||
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
|
||||
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
|
||||
const isPending = !isPassed && !isRejected && !stage.isCurrent
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="relative flex gap-4">
|
||||
{index < stages.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-[15px] top-[32px] h-full w-0.5',
|
||||
getConnectorColor(stage.state)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full',
|
||||
getStateColor(stage.state, stage.isCurrent)
|
||||
)}
|
||||
>
|
||||
{isRejected ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : isPassed ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : stage.isCurrent ? (
|
||||
<Clock className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
isRejected && 'text-destructive',
|
||||
isPending && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{stage.name}
|
||||
</p>
|
||||
{stage.isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</p>
|
||||
{stage.enteredAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(stage.enteredAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal orientation
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0 overflow-x-auto pb-2', className)}>
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stageTypeIcons[stage.stageType] || Circle
|
||||
const isPassed = stage.state === 'PASSED' || stage.state === 'COMPLETED'
|
||||
const isRejected = stage.state === 'REJECTED' || stage.state === 'ELIMINATED'
|
||||
const isPending = !isPassed && !isRejected && !stage.isCurrent
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-8 lg:w-12 shrink-0',
|
||||
getConnectorColor(stages[index - 1].state)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
|
||||
getStateColor(stage.state, stage.isCurrent)
|
||||
)}
|
||||
>
|
||||
{isRejected ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : isPassed ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : stage.isCurrent ? (
|
||||
<Clock className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center max-w-[80px]">
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-medium leading-tight',
|
||||
isRejected && 'text-destructive',
|
||||
isPending && 'text-muted-foreground',
|
||||
stage.isCurrent && 'text-primary'
|
||||
)}
|
||||
>
|
||||
{stage.name}
|
||||
</p>
|
||||
{stage.enteredAt && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatDate(stage.enteredAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
|
||||
interface StageWindowBadgeProps {
|
||||
windowOpenAt?: Date | string | null
|
||||
windowCloseAt?: Date | string | null
|
||||
status?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function toDate(v: Date | string | null | undefined): Date | null {
|
||||
if (!v) return null
|
||||
return typeof v === 'string' ? new Date(v) : v
|
||||
}
|
||||
|
||||
export function StageWindowBadge({
|
||||
windowOpenAt,
|
||||
windowCloseAt,
|
||||
status,
|
||||
className,
|
||||
}: StageWindowBadgeProps) {
|
||||
const now = new Date()
|
||||
const openAt = toDate(windowOpenAt)
|
||||
const closeAt = toDate(windowCloseAt)
|
||||
|
||||
// Determine window state
|
||||
const isBeforeOpen = openAt && now < openAt
|
||||
const isOpenEnded = openAt && !closeAt && now >= openAt
|
||||
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
|
||||
const isClosed = closeAt && now > closeAt
|
||||
|
||||
if (status === 'COMPLETED' || status === 'CLOSED') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground bg-muted',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 shrink-0" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isClosed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground bg-muted',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<XCircle className="h-3 w-3 shrink-0" />
|
||||
<span>Closed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOpenEnded) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>Open</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOpen && closeAt) {
|
||||
const remainingMs = closeAt.getTime() - now.getTime()
|
||||
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
|
||||
|
||||
if (isUrgent) {
|
||||
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>Open</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isBeforeOpen && openAt) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground border-dashed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Timer className="h-3 w-3 shrink-0" />
|
||||
<span>
|
||||
Opens{' '}
|
||||
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No window configured
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground border-dashed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>No window set</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Clock, CheckCircle, XCircle, Timer } from 'lucide-react'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
|
||||
interface StageWindowBadgeProps {
|
||||
windowOpenAt?: Date | string | null
|
||||
windowCloseAt?: Date | string | null
|
||||
status?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function toDate(v: Date | string | null | undefined): Date | null {
|
||||
if (!v) return null
|
||||
return typeof v === 'string' ? new Date(v) : v
|
||||
}
|
||||
|
||||
export function StageWindowBadge({
|
||||
windowOpenAt,
|
||||
windowCloseAt,
|
||||
status,
|
||||
className,
|
||||
}: StageWindowBadgeProps) {
|
||||
const now = new Date()
|
||||
const openAt = toDate(windowOpenAt)
|
||||
const closeAt = toDate(windowCloseAt)
|
||||
|
||||
// Determine window state
|
||||
const isBeforeOpen = openAt && now < openAt
|
||||
const isOpenEnded = openAt && !closeAt && now >= openAt
|
||||
const isOpen = openAt && closeAt && now >= openAt && now <= closeAt
|
||||
const isClosed = closeAt && now > closeAt
|
||||
|
||||
if (status === 'COMPLETED' || status === 'CLOSED') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground bg-muted',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 shrink-0" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isClosed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground bg-muted',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<XCircle className="h-3 w-3 shrink-0" />
|
||||
<span>Closed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOpenEnded) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>Open</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOpen && closeAt) {
|
||||
const remainingMs = closeAt.getTime() - now.getTime()
|
||||
const isUrgent = remainingMs < 24 * 60 * 60 * 1000 // < 24 hours
|
||||
|
||||
if (isUrgent) {
|
||||
return <CountdownTimer deadline={closeAt} label="Closes in" className={className} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>Open</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isBeforeOpen && openAt) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground border-dashed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Timer className="h-3 w-3 shrink-0" />
|
||||
<span>
|
||||
Opens{' '}
|
||||
{openAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No window configured
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
|
||||
'text-muted-foreground border-dashed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>No window set</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
|
||||
// Round statuses
|
||||
DRAFT: { variant: 'secondary' },
|
||||
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
|
||||
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
|
||||
|
||||
// Project statuses
|
||||
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
|
||||
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
|
||||
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
|
||||
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
|
||||
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
|
||||
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
|
||||
REJECTED: { variant: 'destructive' },
|
||||
WITHDRAWN: { variant: 'secondary' },
|
||||
|
||||
// Evaluation statuses
|
||||
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
|
||||
// User statuses
|
||||
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
|
||||
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
|
||||
INACTIVE: { variant: 'secondary' },
|
||||
SUSPENDED: { variant: 'destructive' },
|
||||
}
|
||||
|
||||
type StatusBadgeProps = {
|
||||
status: string
|
||||
className?: string
|
||||
size?: 'sm' | 'default'
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
||||
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
||||
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={style.variant}
|
||||
className={cn(
|
||||
style.className,
|
||||
size === 'sm' && 'text-[10px] px-1.5 py-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const STATUS_STYLES: Record<string, { variant: BadgeProps['variant']; className?: string }> = {
|
||||
// Round statuses
|
||||
DRAFT: { variant: 'secondary' },
|
||||
ACTIVE: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
EVALUATION: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
|
||||
CLOSED: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-600 border-slate-200' },
|
||||
|
||||
// Project statuses
|
||||
SUBMITTED: { variant: 'secondary', className: 'bg-indigo-500/10 text-indigo-700 border-indigo-200 dark:text-indigo-400' },
|
||||
ELIGIBLE: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
ASSIGNED: { variant: 'default', className: 'bg-violet-500/10 text-violet-700 border-violet-200 dark:text-violet-400' },
|
||||
UNDER_REVIEW: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
SHORTLISTED: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
|
||||
SEMIFINALIST: { variant: 'default', className: 'bg-amber-500/10 text-amber-700 border-amber-200 dark:text-amber-400' },
|
||||
FINALIST: { variant: 'default', className: 'bg-orange-500/10 text-orange-700 border-orange-200 dark:text-orange-400' },
|
||||
WINNER: { variant: 'default', className: 'bg-yellow-500/10 text-yellow-800 border-yellow-300 dark:text-yellow-400' },
|
||||
REJECTED: { variant: 'destructive' },
|
||||
WITHDRAWN: { variant: 'secondary' },
|
||||
|
||||
// Evaluation statuses
|
||||
IN_PROGRESS: { variant: 'default', className: 'bg-blue-500/10 text-blue-700 border-blue-200 dark:text-blue-400' },
|
||||
COMPLETED: { variant: 'default', className: 'bg-emerald-500/10 text-emerald-700 border-emerald-200 dark:text-emerald-400' },
|
||||
|
||||
// User statuses
|
||||
NONE: { variant: 'secondary', className: 'bg-slate-500/10 text-slate-500 border-slate-200 dark:text-slate-400' },
|
||||
INVITED: { variant: 'secondary', className: 'bg-sky-500/10 text-sky-700 border-sky-200 dark:text-sky-400' },
|
||||
INACTIVE: { variant: 'secondary' },
|
||||
SUSPENDED: { variant: 'destructive' },
|
||||
}
|
||||
|
||||
type StatusBadgeProps = {
|
||||
status: string
|
||||
className?: string
|
||||
size?: 'sm' | 'default'
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className, size = 'default' }: StatusBadgeProps) {
|
||||
const style = STATUS_STYLES[status] || { variant: 'secondary' as const }
|
||||
const label = status === 'NONE' ? 'NOT INVITED' : status.replace(/_/g, ' ')
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={style.variant}
|
||||
className={cn(
|
||||
style.className,
|
||||
size === 'sm' && 'text-[10px] px-1.5 py-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user