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:
11 files changed
+1800
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user