MOPC-App/src/components/shared/discussion-thread.tsx

146 lines
4.6 KiB
TypeScript
Raw Normal View History

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>
2026-02-05 23:31:41 +01:00
'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>
)
}