feat: add new API Implementation Guide for Modules B, F, H

Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
v0andcetinmustafa83 committed 2026-07-31 13:53:06 +00:00
1 parent 8b33280c20
commit 0c9a46c163
11 files changed
+1800

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { ExamSession, ExamEvent } from '@prisma/client';
import {
getExamTimeRemaining,
formatTimeRemaining,
shouldShowTimeWarning,
calculateSecurityMetrics,
DEFAULT_EXAM_CONFIG,
} from '@/lib/exam-utils';
import { AlertCircle, Clock, Eye, AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface ExamSessionMonitorProps {
examSession: ExamSession & { events: ExamEvent[] };
durationMinutes: number;
onTimeWarning?: (minutesRemaining: number) => void;
onSuspiciousActivity?: (event: ExamEvent) => void;
}
export function ExamSessionMonitor({
examSession,
durationMinutes,
onTimeWarning,
onSuspiciousActivity,
}: ExamSessionMonitorProps) {
const [timeRemaining, setTimeRemaining] = useState(
getExamTimeRemaining(examSession.startTime, durationMinutes)
);
const [securityMetrics, setSecurityMetrics] = useState(
calculateSecurityMetrics(examSession.events)
);
const [isWarningVisible, setIsWarningVisible] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// Update time remaining every second
useEffect(() => {
timerRef.current = setInterval(() => {
const remaining = getExamTimeRemaining(examSession.startTime, durationMinutes);
setTimeRemaining(remaining);
// Check for time warnings
if (shouldShowTimeWarning(remaining, DEFAULT_EXAM_CONFIG.timeWarnings)) {
setIsWarningVisible(true);
onTimeWarning?.(remaining);
setTimeout(() => setIsWarningVisible(false), 3000);
}
// Auto-submit if time's up
if (remaining === 0) {
handleTimeExpired();
}
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [examSession.startTime, durationMinutes, onTimeWarning]);
// Update security metrics when events change
useEffect(() => {
const metrics = calculateSecurityMetrics(examSession.events);
setSecurityMetrics(metrics);
}, [examSession.events]);
const handleTimeExpired = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
// Trigger form submission via event
window.dispatchEvent(new CustomEvent('exam-time-expired'));
}, []);
const getRiskColor = (score: number): string => {
if (score >= 70) return 'text-red-600';
if (score >= 40) return 'text-orange-600';
return 'text-yellow-600';
};
const getTimeColor = (minutes: number): string => {
if (minutes <= 1) return 'text-red-600 font-bold';
if (minutes <= 5) return 'text-orange-600 font-semibold';
return 'text-gray-700';
};
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-3">
<div className="flex items-center justify-between">
{/* Time Remaining */}
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-gray-500" />
<span className={`text-lg font-mono ${getTimeColor(timeRemaining)}`}>
{formatTimeRemaining(timeRemaining)}
</span>
</div>
{/* Security Status */}
{examSession.lockdownEnabled && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<Eye className="w-4 h-4 text-gray-500" />
<span className="text-gray-600">Exam Mode Active</span>
</div>
{securityMetrics.riskScore > 0 && (
<div className={`flex items-center gap-1 text-sm ${getRiskColor(securityMetrics.riskScore)}`}>
<AlertTriangle className="w-4 h-4" />
<span>Risk: {securityMetrics.riskScore}%</span>
</div>
)}
</div>
)}
{/* Event Count */}
<div className="text-sm text-gray-600">
Events: {examSession.events.length}
</div>
</div>
{/* Time Warning Alert */}
{isWarningVisible && timeRemaining <= 5 && (
<Alert className="mt-2 border-orange-300 bg-orange-50">
<AlertCircle className="h-4 w-4 text-orange-600" />
<AlertDescription className="text-orange-800">
{timeRemaining === 0
? 'Time has expired. Your exam will be submitted automatically.'
: `${timeRemaining} minute${timeRemaining !== 1 ? 's' : ''} remaining. Make sure to submit your answers.`}
</AlertDescription>
</Alert>
)}
{/* Suspicious Activity Alert */}
{securityMetrics.suspiciousEvents > 0 && (
<Alert className="mt-2 border-red-300 bg-red-50">
<AlertCircle className="h-4 w-4 text-red-600" />
<AlertDescription className="text-red-800">
Unusual activity detected ({securityMetrics.suspiciousEvents} event
{securityMetrics.suspiciousEvents !== 1 ? 's' : ''}). This will be reviewed by your instructor.
</AlertDescription>
</Alert>
)}
</div>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useApi } from '@/lib/hooks/useApi';
import { Bell, X, Check, Archive, AlertCircle, Info, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatDistanceToNow } from 'date-fns';
interface Notification {
id: string;
title: string;
message: string;
category: string;
priority: string;
isRead: boolean;
isDismissed: boolean;
actionUrl?: string;
actionLabel?: string;
createdAt: string;
}
export function NotificationHub() {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const { data: hubData, mutate } = useApi('/api/v1/notifications/hub');
useEffect(() => {
if (hubData) {
setNotifications(hubData.filter((n: any) => !n.isDismissed));
}
}, [hubData]);
const unreadCount = notifications.filter((n) => !n.isRead).length;
const criticalCount = notifications.filter((n) => n.priority === 'critical' && !n.isRead).length;
const handleMarkAsRead = async (notificationId: string) => {
await fetch(`/api/v1/notifications/${notificationId}/read`, {
method: 'PATCH',
});
mutate();
};
const handleDismiss = async (notificationId: string) => {
await fetch(`/api/v1/notifications/${notificationId}/dismiss`, {
method: 'PATCH',
});
mutate();
};
const handleArchive = async (notificationId: string) => {
await fetch(`/api/v1/notifications/${notificationId}/archive`, {
method: 'PATCH',
});
mutate();
};
const getIcon = (category: string) => {
switch (category) {
case 'assessment':
return <CheckCircle className="w-4 h-4 text-blue-500" />;
case 'behavior':
return <AlertCircle className="w-4 h-4 text-red-500" />;
case 'communication':
return <Bell className="w-4 h-4 text-purple-500" />;
default:
return <Info className="w-4 h-4 text-gray-500" />;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'critical':
return 'border-l-4 border-l-red-500 bg-red-50';
case 'high':
return 'border-l-4 border-l-orange-500 bg-orange-50';
case 'normal':
return 'border-l-4 border-l-blue-500 bg-blue-50';
default:
return 'border-l-4 border-l-gray-300 bg-gray-50';
}
};
return (
<div className="relative">
{/* Notification Bell Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 hover:text-gray-900 rounded-lg hover:bg-gray-100"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className={`absolute top-0 right-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold text-white ${
criticalCount > 0 ? 'bg-red-600' : 'bg-blue-600'
}`}>
{unreadCount}
</span>
)}
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-50 max-h-96 overflow-y-auto">
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Notifications List */}
{notifications.length === 0 ? (
<div className="p-8 text-center">
<Bell className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500">No new notifications</p>
</div>
) : (
<div className="divide-y">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-gray-50 transition ${getPriorityColor(notification.priority)}`}
>
<div className="flex gap-3">
<div className="flex-shrink-0">
{getIcon(notification.category)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm font-medium ${notification.isRead ? 'text-gray-600' : 'text-gray-900 font-bold'}`}>
{notification.title}
</p>
{!notification.isRead && (
<div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0 mt-1" />
)}
</div>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-500 mt-2">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
{notification.actionUrl && (
<a
href={notification.actionUrl}
className="inline-block text-xs text-blue-600 hover:text-blue-800 font-medium mt-2"
>
{notification.actionLabel || 'View'}
</a>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{!notification.isRead && (
<button
onClick={() => handleMarkAsRead(notification.id)}
className="p-1 text-gray-400 hover:text-blue-600"
title="Mark as read"
>
<Check className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleArchive(notification.id)}
className="p-1 text-gray-400 hover:text-gray-600"
title="Archive"
>
<Archive className="w-4 h-4" />
</button>
<button
onClick={() => handleDismiss(notification.id)}
className="p-1 text-gray-400 hover:text-red-600"
title="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* View All Link */}
{notifications.length > 0 && (
<div className="p-3 border-t border-gray-200 text-center">
<a
href="/notifications"
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
View all notifications
</a>
</div>
)}
</div>
)}
</div>
);
}