Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
145
src/components/shared/discussion-thread.tsx
Normal file
145
src/components/shared/discussion-thread.tsx
Normal file
@@ -0,0 +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>
|
||||
)
|
||||
}
|
||||
103
src/components/shared/file-preview.tsx
Normal file
103
src/components/shared/file-preview.tsx
Normal file
@@ -0,0 +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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
FileText,
|
||||
Video,
|
||||
@@ -17,8 +24,11 @@ import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
History,
|
||||
PackageOpen,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ProjectFile {
|
||||
id: string
|
||||
@@ -28,10 +38,12 @@ interface ProjectFile {
|
||||
size: number
|
||||
bucket: string
|
||||
objectKey: string
|
||||
version?: number
|
||||
}
|
||||
|
||||
interface FileViewerProps {
|
||||
files: ProjectFile[]
|
||||
projectId?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -71,7 +83,7 @@ function getFileTypeLabel(fileType: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function FileViewer({ files, className }: FileViewerProps) {
|
||||
export function FileViewer({ files, projectId, className }: FileViewerProps) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
@@ -94,8 +106,11 @@ export function FileViewer({ files, className }: FileViewerProps) {
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-lg">Project Files</CardTitle>
|
||||
{projectId && files.length > 1 && (
|
||||
<BulkDownloadButton projectId={projectId} fileIds={files.map((f) => f.id)} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sortedFiles.map((file) => (
|
||||
@@ -115,7 +130,10 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||
{ enabled: showPreview }
|
||||
)
|
||||
|
||||
const canPreview = file.mimeType.startsWith('video/') || file.mimeType === 'application/pdf'
|
||||
const canPreview =
|
||||
file.mimeType.startsWith('video/') ||
|
||||
file.mimeType === 'application/pdf' ||
|
||||
file.mimeType.startsWith('image/')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -125,7 +143,14 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.fileName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium truncate">{file.fileName}</p>
|
||||
{file.version != null && file.version > 1 && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
v{file.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getFileTypeLabel(file.fileType)}
|
||||
@@ -134,7 +159,10 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{file.version != null && file.version > 1 && (
|
||||
<VersionHistoryButton fileId={file.id} />
|
||||
)}
|
||||
{canPreview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -179,6 +207,179 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VersionHistoryButton({ fileId }: { fileId: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data: versions, isLoading } = trpc.file.getVersionHistory.useQuery(
|
||||
{ fileId },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" title="Version history">
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Version History</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : versions && (versions as Array<Record<string, unknown>>).length > 0 ? (
|
||||
(versions as Array<Record<string, unknown>>).map((v) => (
|
||||
<div
|
||||
key={String(v.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3',
|
||||
String(v.id) === fileId && 'border-primary bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{String(v.fileName)}
|
||||
</p>
|
||||
<Badge variant={String(v.id) === fileId ? 'default' : 'outline'} className="text-xs shrink-0">
|
||||
v{String(v.version)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatFileSize(Number(v.size))}</span>
|
||||
<span>
|
||||
{v.createdAt
|
||||
? new Date(String(v.createdAt)).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<VersionDownloadButton
|
||||
bucket={String(v.bucket)}
|
||||
objectKey={String(v.objectKey)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No version history available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionDownloadButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket, objectKey },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true)
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (result.data?.url) {
|
||||
window.open(result.data.url, '_blank')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to get download URL')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
aria-label="Download this version"
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds: string[] }) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const { refetch } = trpc.file.getBulkDownloadUrls.useQuery(
|
||||
{ projectId, fileIds },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleBulkDownload = async () => {
|
||||
setDownloading(true)
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
// Open each download URL with a small delay to avoid popup blocking
|
||||
for (let i = 0; i < result.data.length; i++) {
|
||||
const item = result.data[i] as { downloadUrl: string }
|
||||
if (item.downloadUrl) {
|
||||
// Use link element to trigger download without popup
|
||||
const link = document.createElement('a')
|
||||
link.href = item.downloadUrl
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// Small delay between downloads
|
||||
if (i < result.data.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success(`Downloading ${result.data.length} files`)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to download files')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FileDownloadButton({ file }: { file: ProjectFile }) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
@@ -256,6 +457,29 @@ function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center p-4">
|
||||
<img
|
||||
src={url}
|
||||
alt={file.fileName}
|
||||
className="max-w-full max-h-[500px] object-contain rounded-md"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
asChild
|
||||
>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
Preview not available for this file type
|
||||
@@ -314,6 +538,11 @@ function CompactFileItem({ file }: { file: ProjectFile }) {
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm">{file.fileName}</span>
|
||||
{file.version != null && file.version > 1 && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
v{file.version}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
|
||||
61
src/components/shared/language-switcher.tsx
Normal file
61
src/components/shared/language-switcher.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Globe, Check } from 'lucide-react'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English', flag: 'EN' },
|
||||
{ code: 'fr', label: 'Fran\u00e7ais', flag: 'FR' },
|
||||
] as const
|
||||
|
||||
type LanguageCode = (typeof LANGUAGES)[number]['code']
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale() as LanguageCode
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const currentLang = LANGUAGES.find((l) => l.code === locale) ?? LANGUAGES[0]
|
||||
|
||||
const switchLanguage = (code: LanguageCode) => {
|
||||
// Set cookie with 1 year expiry
|
||||
document.cookie = `locale=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`
|
||||
// Refresh to re-run server components with new locale
|
||||
startTransition(() => {
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2" disabled={isPending}>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="font-medium">{currentLang.flag}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => switchLanguage(lang.code)}
|
||||
className="gap-2"
|
||||
>
|
||||
<span className="font-medium w-6">{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
{locale === lang.code && <Check className="ml-auto h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
173
src/components/shared/live-score-animation.tsx
Normal file
173
src/components/shared/live-score-animation.tsx
Normal file
@@ -0,0 +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>
|
||||
)
|
||||
}
|
||||
135
src/components/shared/qr-code-display.tsx
Normal file
135
src/components/shared/qr-code-display.tsx
Normal file
@@ -0,0 +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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user