Round 12: WebSocket Collaboration, Curriculum Mapping, Attendance Analytics, Data Backup, Mobile Optimization, Performance

This commit is contained in:
Mustafa Cetin committed 2026-07-29 09:47:32 +00:00
1 parent 21310d3f62
commit ba55bd13e4
24 files changed
+6067 -947

No files matched your search

+22
View File
@@ -114,6 +114,7 @@ import {
CommandShortcut,
} from '@/components/ui/command';
import { toast } from 'sonner';
import { useWebSocket, usePushNotifications, playNotificationSound, getNotificationSoundPref } from '@/lib/websocket';
import DashboardView from './dashboard-view';
import ClassesView from './classes-view';
@@ -256,6 +257,10 @@ export default function AppLayout() {
const [schoolYears, setSchoolYears] = useState<SchoolYear[]>([]);
const [selectedYearId, setSelectedYearId] = useState<string>(storeSchoolYearId ?? '');
// WebSocket connection for real-time push notifications
const { connected: wsConnected } = useWebSocket();
const { notification: pushNotification } = usePushNotifications();
// Quick search (Cmd+K) state
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -500,6 +505,23 @@ export default function AppLayout() {
return () => clearInterval(interval);
}, [loadNotifications]);
// Handle push notifications in real-time (after loadNotifications is defined)
useEffect(() => {
if (!pushNotification) return;
// Play notification sound if enabled
if (getNotificationSoundPref()) {
playNotificationSound();
}
// Show toast notification
const typeInfo = getNotifTypeInfo(pushNotification.type);
toast(typeInfo.label, {
description: pushNotification.message,
duration: 5000,
});
// Refresh notification data to update the bell count
loadNotifications();
}, [pushNotification, loadNotifications]);
const handleMarkAllRead = useCallback(async () => {
if (!notifData || notifData.unreadCount === 0) return;
try {
+364
View File
@@ -24,7 +24,25 @@ import {
BarChart3,
LucideIcon,
FilePenLine,
Download,
AlertTriangle,
Shield,
} from 'lucide-react';
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -71,11 +89,13 @@ import {
deleteAttendanceSession,
fetchClasses,
fetchSubjects,
fetchAttendanceAnalytics,
type AttendanceSession,
type AttendanceRecord,
type AttendanceStatus,
type ClassGroup,
type Subject,
type AttendanceAnalyticsData,
} from '@/lib/api';
/* ── Status helpers ────────────────────────────────────────────────── */
@@ -689,6 +709,341 @@ function SessionDetail({
);
}
/* ── Attendance Analytics Tab Component ──────────────────────────── */
function AttendanceAnalyticsTab() {
const currentUser = useAppStore((s) => s.currentUser);
const schoolId = currentUser?.schoolId;
const currentClassId = useAppStore((s) => s.currentClassId);
const [analyticsData, setAnalyticsData] = useState<AttendanceAnalyticsData | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
const loadAnalytics = useCallback(async () => {
if (!schoolId) return;
setAnalyticsLoading(true);
try {
const data = await fetchAttendanceAnalytics(schoolId, currentClassId ?? undefined);
setAnalyticsData(data);
} catch {
// silent
} finally {
setAnalyticsLoading(false);
}
}, [schoolId, currentClassId]);
useEffect(() => {
loadAnalytics();
}, [loadAnalytics]);
const exportCSV = useCallback(() => {
if (!analyticsData) return;
const rows: string[] = [];
rows.push('Week,Attendance Rate,Absent Rate,Present,Absent,Excused,Late,Total');
for (const row of analyticsData.trendData) {
rows.push(`${row.week},${row.attendanceRate},${row.absentRate},${row.present},${row.absent},${row.excused},${row.late},${row.total}`);
}
rows.push('');
rows.push('Student,FirstName,LastName,Absence Rate,Total Absences,Total Sessions,Risk Level');
for (const row of analyticsData.riskIndicators) {
rows.push(`${row.studentId},${row.firstName},${row.lastName},${row.absenceRate},${row.totalAbsences},${row.totalSessions},${row.riskLevel}`);
}
const csvContent = rows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'attendance-analytics.csv';
a.click();
URL.revokeObjectURL(url);
}, [analyticsData]);
if (analyticsLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
);
}
if (!analyticsData || analyticsData.totalRecords === 0) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 mx-auto mb-5 shadow-md">
<BarChart3 className="h-10 w-10 text-emerald-500" />
</div>
<h3 className="text-lg font-semibold mb-1">{t('attendance.no_analytics_data')}</h3>
<p className="text-sm text-muted-foreground">{t('attendance.no_analytics_desc')}</p>
</motion.div>
);
}
const CHART_COLORS = ['#10b981', '#14b8a6', '#f59e0b', '#ef4444'];
return (
<div className="space-y-4">
{/* Export button */}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={exportCSV}>
<Download className="h-4 w-4 mr-2" />
{t('attendance.export_csv')}
</Button>
</div>
{/* Overview Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card className="border-2 border-emerald-200 dark:border-emerald-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-emerald-600">{analyticsData.totalSessions}</div>
<div className="text-xs text-muted-foreground">{t('attendance.total_sessions')}</div>
</CardContent>
</Card>
<Card className="border-2 border-teal-200 dark:border-teal-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-teal-600">{analyticsData.totalRecords}</div>
<div className="text-xs text-muted-foreground">{t('attendance.total_records')}</div>
</CardContent>
</Card>
<Card className="border-2 border-amber-200 dark:border-amber-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-amber-600">{analyticsData.absencePatterns.length}</div>
<div className="text-xs text-muted-foreground">{t('attendance.chronic_absence')}</div>
</CardContent>
</Card>
<Card className="border-2 border-rose-200 dark:border-rose-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-rose-600">{analyticsData.riskIndicators.length}</div>
<div className="text-xs text-muted-foreground">{t('attendance.risk_indicators')}</div>
</CardContent>
</Card>
</div>
{/* Attendance Trends Line Chart */}
{analyticsData.trendData.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
{t('attendance.weekly_trend')}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={analyticsData.trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="week"
tickFormatter={(v: string) => {
const d = new Date(v);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}}
tick={{ fontSize: 11 }}
/>
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} />
<RechartsTooltip />
<Legend />
<Line
type="monotone"
dataKey="attendanceRate"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: '#10b981', r: 4 }}
name={t('attendance.attendance_rate_over_time')}
/>
<Line
type="monotone"
dataKey="absentRate"
stroke="#ef4444"
strokeWidth={2}
dot={{ fill: '#ef4444', r: 4 }}
name={t('attendance.absence_rate_over_time')}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Day-of-Week Bar Chart */}
{analyticsData.dayOfWeekAnalysis.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-teal-500" />
{t('attendance.absences_by_day')}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={analyticsData.dayOfWeekAnalysis}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="dayNameDe" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<RechartsTooltip />
<Legend />
<Bar dataKey="attendanceRate" fill="#10b981" name={t('attendance.rate')} radius={[4, 4, 0, 0]} />
<Bar dataKey="absentCount" fill="#ef4444" name={t('attendance.absent_count_long')} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Status Distribution Pie Chart */}
{analyticsData.statusDistribution.present + analyticsData.statusDistribution.absent + analyticsData.statusDistribution.excused + analyticsData.statusDistribution.late > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Shield className="h-4 w-4 text-amber-500" />
{t('attendance.status_distribution')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={[
{ name: t('attendance.present'), value: analyticsData.statusDistribution.present },
{ name: t('attendance.absent'), value: analyticsData.statusDistribution.absent },
{ name: t('attendance.excused'), value: analyticsData.statusDistribution.excused },
{ name: t('attendance.late'), value: analyticsData.statusDistribution.late },
]}
cx="50%"
cy="50%"
outerRadius={80}
dataKey="value"
label={({ name, value }: { name: string; value: number }) => `${name}: ${value}`}
>
{[
{ name: t('attendance.present'), value: analyticsData.statusDistribution.present },
{ name: t('attendance.absent'), value: analyticsData.statusDistribution.absent },
{ name: t('attendance.excused'), value: analyticsData.statusDistribution.excused },
{ name: t('attendance.late'), value: analyticsData.statusDistribution.late },
].map((_, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index]} />
))}
</Pie>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)}
{/* Chronic Absence */}
{analyticsData.absencePatterns.length > 0 && (
<Card className="border-2 border-amber-200 dark:border-amber-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
{t('attendance.chronic_absence')}
</CardTitle>
<p className="text-xs text-muted-foreground">{t('attendance.chronic_absence_desc')}</p>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-64 overflow-y-auto">
{analyticsData.absencePatterns.map((s) => (
<div key={s.studentId} className="flex items-center justify-between p-3 rounded-lg border bg-amber-50/50 dark:bg-amber-950/20">
<div>
<span className="font-medium text-sm">{s.lastName}, {s.firstName}</span>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300">
{s.absenceRate}% {t('attendance.absent_count_long')}
</Badge>
<Badge variant="outline" className="text-xs">
{s.absent}/{s.total}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Risk Indicators */}
{analyticsData.riskIndicators.length > 0 && (
<Card className="border-2 border-rose-200 dark:border-rose-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-rose-500" />
{t('attendance.risk_indicators')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-64 overflow-y-auto">
{analyticsData.riskIndicators.map((s) => {
const riskColors: Record<string, string> = {
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
critical: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
};
const riskLabels: Record<string, string> = {
medium: t('attendance.risk_medium'),
high: t('attendance.risk_high'),
critical: t('attendance.risk_critical'),
};
return (
<div key={s.studentId} className="flex items-center justify-between p-3 rounded-lg border bg-rose-50/50 dark:bg-rose-950/20">
<div>
<span className="font-medium text-sm">{s.lastName}, {s.firstName}</span>
</div>
<div className="flex items-center gap-3">
<Badge className={riskColors[s.riskLevel] ?? ''}>
{riskLabels[s.riskLevel] ?? s.riskLevel}
</Badge>
<Badge variant="outline" className="text-xs">
{s.absenceRate}%
</Badge>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Class Comparison */}
{analyticsData.classComparison.length > 1 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Users className="h-4 w-4 text-emerald-500" />
{t('attendance.class_comparison')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{analyticsData.classComparison.map((c) => (
<div key={c.classGroupId} className="flex items-center gap-3">
<span className="text-sm font-medium w-24 truncate">{c.className}</span>
<div className="flex-1">
<div className="bg-gray-100 dark:bg-gray-800 rounded-full h-6 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
c.attendanceRate >= 90 ? 'bg-emerald-500' :
c.attendanceRate >= 70 ? 'bg-amber-500' :
'bg-rose-500'
}`}
style={{ width: `${Math.max(c.attendanceRate, 2)}%` }}
/>
</div>
</div>
<span className="text-sm font-medium">{c.attendanceRate}%</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
/* ── Main Attendance View ──────────────────────────────────────────── */
export default function AttendanceView() {
@@ -1114,6 +1469,10 @@ export default function AttendanceView() {
<TabsTrigger value="stats" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.stats')}
</TabsTrigger>
<TabsTrigger value="analytics" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
<BarChart3 className="h-4 w-4 mr-1" />
{t('attendance.analytics')}
</TabsTrigger>
<TabsTrigger value="calendar" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
{t('attendance.calendar')}
</TabsTrigger>
@@ -1282,6 +1641,11 @@ export default function AttendanceView() {
<StatsCards sessions={sessions} />
</TabsContent>
{/* Analytics Tab */}
<TabsContent value="analytics" className="space-y-4">
<AttendanceAnalyticsTab />
</TabsContent>
{/* Calendar Tab */}
<TabsContent value="calendar" className="space-y-4">
<MonthlyHeatmap sessions={sessions} />
File diff suppressed because it is too large. Load diff
+385 -121
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState, useMemo, memo } from 'react';
import {
Pencil,
PenTool,
@@ -25,6 +25,7 @@ import {
ChevronDown,
ZoomIn,
ZoomOut,
Activity,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
@@ -47,6 +48,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
DrawerDescription,
} from '@/components/ui/drawer';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -103,7 +112,10 @@ const PEN_COLORS = [
];
const AUTO_SAVE_INTERVAL = 30000;
const AUTO_SAVE_DEBOUNCE = 5000; // Debounced auto-save: 5s after last stroke
const LOCAL_STORAGE_KEY = 'ct_drawing_draft';
const POINT_SIMPLIFICATION_TOLERANCE = 2; // Distance threshold for point simplification
const DRAWING_RAF_THRESHOLD = 16; // ~60fps target
/* ── Utility ───────────────────────────────────────────────────────── */
@@ -111,6 +123,39 @@ function generateId(): string {
return `stroke_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/* ── Point Simplification (Ramer-Douglas-Peucker) ──────────────────── */
function distToSegment(p: StrokePoint, a: StrokePoint, b: StrokePoint): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
function simplifyPoints(points: StrokePoint[], tolerance: number): StrokePoint[] {
if (points.length <= 2) return points;
let maxDist = 0;
let maxIdx = 0;
const first = points[0];
const last = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = distToSegment(points[i], first, last);
if (d > maxDist) {
maxDist = d;
maxIdx = i;
}
}
if (maxDist > tolerance) {
const left = simplifyPoints(points.slice(0, maxIdx + 1), tolerance);
const right = simplifyPoints(points.slice(maxIdx), tolerance);
return [...left.slice(0, -1), ...right];
}
return [first, last];
}
function getCanvasPoint(
canvas: HTMLCanvasElement,
e: React.MouseEvent | React.TouchEvent | PointerEvent
@@ -408,9 +453,29 @@ export default function DrawingCanvas({
}: DrawingCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Background layer canvas for caching (avoids redrawing background every frame)
const bgCanvasRef = useRef<HTMLCanvasElement | null>(null);
// RAF tracking
const rafIdRef = useRef<number>(0);
const lastDrawTimeRef = useRef<number>(0);
// Debounced auto-save timer
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FPS counter
const fpsFrameCountRef = useRef<number>(0);
const fpsLastTimeRef = useRef<number>(performance.now());
const [fpsDisplay, setFpsDisplay] = useState<number>(0);
const [showFps, setShowFps] = useState(false);
// Drawing state
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [strokes, setStrokes] = useState<Stroke[]>(() => {
if (!initialDrawingData) return [];
try {
const parsed = JSON.parse(initialDrawingData) as Stroke[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
const [redoStack, setRedoStack] = useState<Stroke[]>([]);
const [currentStroke, setCurrentStroke] = useState<Stroke | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
@@ -436,22 +501,14 @@ export default function DrawingCanvas({
// Zoom state
const [zoomLevel, setZoomLevel] = useState(100);
/* ── Load initial data ─────────────────────────────────────────── */
// Detect mobile for drawer vs dialog
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (initialDrawingData) {
try {
const parsed = JSON.parse(initialDrawingData) as Stroke[];
if (Array.isArray(parsed)) {
setStrokes(parsed);
}
} catch {
// ignore invalid data
}
}
}, [initialDrawingData]);
/* ── Resize canvas to container ─────────────────────────────────── */
const check = () => setIsMobile(window.innerWidth < 640);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
useEffect(() => {
const updateSize = () => {
@@ -468,7 +525,29 @@ export default function DrawingCanvas({
return () => window.removeEventListener('resize', updateSize);
}, []);
/* ── Redraw canvas ──────────────────────────────────────────────── */
/* ── Build background layer cache ──────────────────────────────── */
const buildBgCache = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!bgCanvasRef.current) {
bgCanvasRef.current = document.createElement('canvas');
}
bgCanvasRef.current.width = canvas.width;
bgCanvasRef.current.height = canvas.height;
const ctx = bgCanvasRef.current.getContext('2d');
if (!ctx) return;
drawBackground(ctx, canvas.width, canvas.height, bgType);
if (guideMode !== 'off') {
drawGuideOverlay(ctx, canvas.width, canvas.height, guideMode);
}
}, [bgType, guideMode]);
useEffect(() => {
buildBgCache();
}, [buildBgCache]);
/* ── Redraw canvas with RAF + layer caching ──────────────────── */
const redrawCanvas = useCallback(() => {
const canvas = canvasRef.current;
@@ -476,12 +555,14 @@ export default function DrawingCanvas({
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear and draw background
drawBackground(ctx, canvas.width, canvas.height, bgType);
// Draw guide overlay
if (guideMode !== 'off') {
drawGuideOverlay(ctx, canvas.width, canvas.height, guideMode);
// Use cached background layer
if (bgCanvasRef.current) {
ctx.drawImage(bgCanvasRef.current, 0, 0);
} else {
drawBackground(ctx, canvas.width, canvas.height, bgType);
if (guideMode !== 'off') {
drawGuideOverlay(ctx, canvas.width, canvas.height, guideMode);
}
}
// Draw all committed strokes
@@ -495,12 +576,84 @@ export default function DrawingCanvas({
}
}, [strokes, currentStroke, bgType, guideMode]);
useEffect(() => {
redrawCanvas();
// RAF-based drawing for smooth animation
const scheduleRedraw = useCallback(() => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
redrawCanvas();
rafIdRef.current = 0;
});
}, [redrawCanvas]);
/* ── Auto-save ──────────────────────────────────────────────────── */
useEffect(() => {
scheduleRedraw();
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, [scheduleRedraw]);
/* ── FPS Counter ──────────────────────────────────────────────── */
useEffect(() => {
if (!showFps) return;
const interval = setInterval(() => {
const now = performance.now();
const elapsed = now - fpsLastTimeRef.current;
if (elapsed > 0) {
setFpsDisplay(Math.round((fpsFrameCountRef.current / elapsed) * 1000));
}
fpsFrameCountRef.current = 0;
fpsLastTimeRef.current = now;
}, 1000);
return () => clearInterval(interval);
}, [showFps]);
// Count frames for FPS
useEffect(() => {
if (!showFps) return;
const countFrame = () => {
fpsFrameCountRef.current++;
requestAnimationFrame(countFrame);
};
const id = requestAnimationFrame(countFrame);
return () => cancelAnimationFrame(id);
}, [showFps]);
/* ── Auto-save (debounced) ──────────────────────────────────────── */
const debouncedAutoSave = useCallback(() => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = setTimeout(() => {
if (strokes.length > 0) {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify({ strokes, bgType, title: drawingTitle, updatedAt: new Date().toISOString() })
);
setLastAutoSave(new Date());
} catch {
// ignore storage errors
}
}
}, AUTO_SAVE_DEBOUNCE);
}, [strokes, bgType, drawingTitle]);
useEffect(() => {
debouncedAutoSave();
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, [debouncedAutoSave]);
// Also keep periodic auto-save as backup
useEffect(() => {
const interval = setInterval(() => {
if (strokes.length > 0) {
@@ -581,7 +734,12 @@ export default function DrawingCanvas({
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') {
finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } };
} else {
finalStroke = { ...currentStroke, points: [...currentStroke.points, point] };
// Simplify points for freehand strokes (performance optimization)
const allPoints = [...currentStroke.points, point];
const simplified = allPoints.length > 10
? simplifyPoints(allPoints, POINT_SIMPLIFICATION_TOLERANCE)
: allPoints;
finalStroke = { ...currentStroke, points: simplified };
}
setStrokes((prev) => [...prev, finalStroke]);
@@ -752,8 +910,8 @@ export default function DrawingCanvas({
return (
<div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
{/* ── Toolbar ────────────────────────────────────────────────── */}
<div className="sticky top-0 z-20 border-b border-gray-200 bg-gray-900 px-3 py-2 text-white shadow-lg">
<div className="flex flex-wrap items-center gap-2">
<div className="sticky top-0 z-20 border-b border-gray-200 bg-gray-900 px-2 sm:px-3 py-2 text-white shadow-lg safe-top">
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
{/* Exit button */}
{onExit && (
<TooltipProvider>
@@ -763,7 +921,8 @@ export default function DrawingCanvas({
variant="ghost"
size="sm"
onClick={onExit}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white"
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white"
aria-label={t('drawing.exit')}
>
<X className="h-5 w-5" />
</Button>
@@ -774,7 +933,7 @@ export default function DrawingCanvas({
)}
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Drawing tools */}
<TooltipProvider>
@@ -785,11 +944,13 @@ export default function DrawingCanvas({
variant="ghost"
size="sm"
onClick={() => setActiveTool(tool.id)}
className={`h-10 w-10 transition-all rounded-lg ${
className={`h-10 w-10 min-touch transition-all rounded-lg ${
activeTool === tool.id
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
aria-label={tool.label}
aria-pressed={activeTool === tool.id}
>
{tool.icon}
</Button>
@@ -800,7 +961,7 @@ export default function DrawingCanvas({
</TooltipProvider>
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Color picker */}
<Popover>
@@ -808,16 +969,17 @@ export default function DrawingCanvas({
<Button
variant="ghost"
size="sm"
className="h-10 gap-1.5 px-2 text-gray-300 hover:bg-gray-800 hover:text-white"
className="h-10 min-touch gap-1.5 px-2 text-gray-300 hover:bg-gray-800 hover:text-white"
aria-label={t('drawing.color')}
>
<div
className="h-6 w-6 rounded-full border-2 border-gray-500"
style={{ backgroundColor: strokeColor }}
/>
<Pipette className="h-4 w-4" />
<Pipette className="h-4 w-4 hidden sm:block" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" side="bottom">
<PopoverContent className="w-auto p-3 max-w-[calc(100vw-2rem)]" side="bottom">
<div className="space-y-2">
<p className="text-xs font-medium text-gray-500">{t('drawing.color')}</p>
<div className="grid grid-cols-8 gap-1.5">
@@ -825,12 +987,13 @@ export default function DrawingCanvas({
<button
key={color}
onClick={() => setStrokeColor(color)}
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
className={`h-8 w-8 min-touch rounded-full border-2 transition-all hover:scale-110 ${
strokeColor === color
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
: 'border-gray-300'
}`}
style={{ backgroundColor: color }}
aria-label={`Color ${color}`}
/>
))}
</div>
@@ -842,12 +1005,13 @@ export default function DrawingCanvas({
<button
key={color}
onClick={() => setStrokeColor(color)}
className={`h-8 w-8 rounded-full border-2 transition-all hover:scale-110 ${
className={`h-8 w-8 min-touch rounded-full border-2 transition-all hover:scale-110 ${
strokeColor === color
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
: 'border-gray-300'
}`}
style={{ backgroundColor: color }}
aria-label={`Pen color ${color}`}
/>
))}
</div>
@@ -857,7 +1021,8 @@ export default function DrawingCanvas({
type="color"
value={strokeColor}
onChange={(e) => setStrokeColor(e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
className="h-8 w-8 min-touch cursor-pointer rounded border"
aria-label="Custom color picker"
/>
</div>
</div>
@@ -865,24 +1030,25 @@ export default function DrawingCanvas({
</Popover>
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Stroke width */}
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-gray-400" />
<div className="flex items-center gap-1 sm:gap-2">
<Palette className="h-4 w-4 text-gray-400 hidden sm:block" />
<Slider
value={[strokeWidth]}
min={1}
max={20}
step={1}
onValueChange={(v) => setStrokeWidth(v[0])}
className="w-24"
className="w-16 sm:w-24"
aria-label={t('drawing.stroke_width')}
/>
<span className="min-w-[2rem] text-center text-xs text-gray-400">{strokeWidth}px</span>
</div>
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Background type */}
<Popover>
@@ -890,23 +1056,25 @@ export default function DrawingCanvas({
<Button
variant="ghost"
size="sm"
className="h-10 gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
className="h-10 min-touch gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
aria-label={t('drawing.bg_type')}
>
{bgOptions.find((b) => b.id === bgType)?.icon}
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" side="bottom">
<PopoverContent className="w-auto p-2 max-w-[calc(100vw-2rem)]" side="bottom">
<div className="space-y-1">
{bgOptions.map((bg) => (
<button
key={bg.id}
onClick={() => setBgType(bg.id)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 min-h-[44px] text-sm transition-colors ${
bgType === bg.id
? 'bg-emerald-100 text-emerald-700'
: 'hover:bg-gray-100'
}`}
aria-pressed={bgType === bg.id}
>
{bg.icon}
{bg.label}
@@ -922,7 +1090,8 @@ export default function DrawingCanvas({
<Button
variant="ghost"
size="sm"
className="h-10 gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
className="h-10 min-touch gap-1 text-gray-300 hover:bg-gray-800 hover:text-white"
aria-label={t('drawing.guide_mode')}
>
{guideMode === 'off' ? (
<EyeOff className="h-4 w-4" />
@@ -932,17 +1101,18 @@ export default function DrawingCanvas({
<ChevronDown className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" side="bottom">
<PopoverContent className="w-auto p-2 max-w-[calc(100vw-2rem)]" side="bottom">
<div className="space-y-1">
{guideOptions.map((g) => (
<button
key={g.id}
onClick={() => setGuideMode(g.id)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors ${
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 min-h-[44px] text-sm transition-colors ${
guideMode === g.id
? 'bg-emerald-100 text-emerald-700'
: 'hover:bg-gray-100'
}`}
aria-pressed={guideMode === g.id}
>
{g.label}
</button>
@@ -952,7 +1122,7 @@ export default function DrawingCanvas({
</Popover>
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Undo / Redo */}
<TooltipProvider>
@@ -963,7 +1133,8 @@ export default function DrawingCanvas({
size="sm"
onClick={handleUndo}
disabled={strokes.length === 0}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
aria-label={t('drawing.undo')}
>
<Undo2 className="h-5 w-5" />
</Button>
@@ -977,7 +1148,8 @@ export default function DrawingCanvas({
size="sm"
onClick={handleRedo}
disabled={redoStack.length === 0}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30 transition-transform active:scale-90"
aria-label={t('drawing.redo')}
>
<Redo2 className="h-5 w-5" />
</Button>
@@ -987,7 +1159,7 @@ export default function DrawingCanvas({
</TooltipProvider>
{/* Divider */}
<div className="mx-1 h-8 w-px bg-gray-700" />
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Zoom controls */}
<TooltipProvider>
@@ -998,7 +1170,8 @@ export default function DrawingCanvas({
size="sm"
onClick={() => setZoomLevel(Math.max(25, zoomLevel - 25))}
disabled={zoomLevel <= 25}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
aria-label={t('drawing.zoom_out')}
>
<ZoomOut className="h-5 w-5" />
</Button>
@@ -1013,7 +1186,8 @@ export default function DrawingCanvas({
size="sm"
onClick={() => setZoomLevel(Math.min(200, zoomLevel + 25))}
disabled={zoomLevel >= 200}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white disabled:opacity-30"
aria-label={t('drawing.zoom_in')}
>
<ZoomIn className="h-5 w-5" />
</Button>
@@ -1022,6 +1196,24 @@ export default function DrawingCanvas({
</Tooltip>
</TooltipProvider>
{/* FPS Toggle */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setShowFps(!showFps)}
className={`h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white ${showFps ? 'text-emerald-400' : ''}`}
aria-label={t('performance.title')}
>
<Activity className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('performance.fps')}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Spacer */}
<div className="flex-1" />
@@ -1039,7 +1231,8 @@ export default function DrawingCanvas({
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
className="h-10 w-10 text-gray-300 hover:bg-red-900/50 hover:text-red-300"
className="h-10 w-10 min-touch text-gray-300 hover:bg-red-900/50 hover:text-red-300"
aria-label={t('drawing.clear')}
>
<Trash2 className="h-5 w-5" />
</Button>
@@ -1052,7 +1245,8 @@ export default function DrawingCanvas({
variant="ghost"
size="sm"
onClick={handleExportPNG}
className="h-10 w-10 text-gray-300 hover:bg-gray-800 hover:text-white"
className="h-10 w-10 min-touch text-gray-300 hover:bg-gray-800 hover:text-white"
aria-label={t('drawing.export_png')}
>
<Download className="h-5 w-5" />
</Button>
@@ -1065,7 +1259,8 @@ export default function DrawingCanvas({
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
className="h-10 gap-1 bg-emerald-600 text-white hover:bg-emerald-700"
className="h-10 min-touch gap-1 bg-emerald-600 text-white hover:bg-emerald-700"
aria-label={t('drawing.save')}
>
<Save className="h-5 w-5" />
<span className="hidden sm:inline">{t('drawing.save')}</span>
@@ -1085,13 +1280,20 @@ export default function DrawingCanvas({
)}
</div>
{/* FPS Counter */}
{showFps && (
<div className="fps-counter">
{fpsDisplay} FPS
</div>
)}
{/* ── Canvas Area ────────────────────────────────────────────── */}
<div
ref={containerRef}
className="flex-1 overflow-auto bg-gray-100 p-4"
className="flex-1 overflow-auto bg-gray-100 p-2 sm:p-4"
>
<div
className="relative mx-auto overflow-hidden rounded-lg shadow-lg canvas-toolbar"
className="relative mx-auto overflow-hidden rounded-lg shadow-lg canvas-toolbar canvas-gpu"
style={{
width: `${zoomLevel}%`,
maxWidth: '100%',
@@ -1105,7 +1307,7 @@ export default function DrawingCanvas({
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
className="h-full w-full cursor-crosshair touch-none"
className="h-full w-full cursor-crosshair touch-none canvas-no-zoom canvas-no-select"
style={{
imageRendering: 'auto',
}}
@@ -1113,6 +1315,8 @@ export default function DrawingCanvas({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
role="img"
aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
/>
{/* Tool indicator */}
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2">
@@ -1134,67 +1338,127 @@ export default function DrawingCanvas({
</div>
</div>
{/* ── Clear Dialog ───────────────────────────────────────────── */}
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('drawing.clear_confirm')}</DialogTitle>
<DialogDescription>
{t('drawing.delete_desc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearDialog(false)}>
{t('action.cancel')}
</Button>
<Button variant="destructive" onClick={handleClear}>
{t('drawing.clear')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Clear Dialog / Drawer ───────────────────────────────────── */}
{isMobile ? (
<Drawer open={showClearDialog} onOpenChange={setShowClearDialog}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('drawing.clear_confirm')}</DrawerTitle>
<DrawerDescription>{t('drawing.delete_desc')}</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button variant="outline" onClick={() => setShowClearDialog(false)}>
{t('action.cancel')}
</Button>
<Button variant="destructive" onClick={handleClear}>
{t('drawing.clear')}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : (
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('drawing.clear_confirm')}</DialogTitle>
<DialogDescription>
{t('drawing.delete_desc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearDialog(false)}>
{t('action.cancel')}
</Button>
<Button variant="destructive" onClick={handleClear}>
{t('drawing.clear')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* ── Save Dialog ────────────────────────────────────────────── */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('drawing.save')}</DialogTitle>
<DialogDescription>
{t('drawing.eco_tip')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="drawing-title">{t('drawing.title_label')}</Label>
<Input
id="drawing-title"
value={drawingTitle}
onChange={(e) => setDrawingTitle(e.target.value)}
placeholder={t('drawing.untitled')}
className="mt-1"
/>
{/* ── Save Dialog / Drawer ────────────────────────────────────── */}
{isMobile ? (
<Drawer open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('drawing.save')}</DrawerTitle>
<DrawerDescription>{t('drawing.eco_tip')}</DrawerDescription>
</DrawerHeader>
<div className="px-4 space-y-4">
<div>
<Label htmlFor="drawing-title-mobile">{t('drawing.title_label')}</Label>
<Input
id="drawing-title-mobile"
value={drawingTitle}
onChange={(e) => setDrawingTitle(e.target.value)}
placeholder={t('drawing.untitled')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="drawing-desc-mobile">{t('drawing.description_label')}</Label>
<Textarea
id="drawing-desc-mobile"
value={drawingDescription}
onChange={(e) => setDrawingDescription(e.target.value)}
className="mt-1"
rows={3}
/>
</div>
</div>
<div>
<Label htmlFor="drawing-desc">{t('drawing.description_label')}</Label>
<Textarea
id="drawing-desc"
value={drawingDescription}
onChange={(e) => setDrawingDescription(e.target.value)}
className="mt-1"
rows={3}
/>
<DrawerFooter>
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving} className="bg-emerald-600 hover:bg-emerald-700">
{isSaving ? t('drawing.saving') : t('drawing.save')}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : (
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('drawing.save')}</DialogTitle>
<DialogDescription>
{t('drawing.eco_tip')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="drawing-title">{t('drawing.title_label')}</Label>
<Input
id="drawing-title"
value={drawingTitle}
onChange={(e) => setDrawingTitle(e.target.value)}
placeholder={t('drawing.untitled')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="drawing-desc">{t('drawing.description_label')}</Label>
<Textarea
id="drawing-desc"
value={drawingDescription}
onChange={(e) => setDrawingDescription(e.target.value)}
className="mt-1"
rows={3}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving} className="bg-emerald-600 hover:bg-emerald-700">
{isSaving ? t('drawing.saving') : t('drawing.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
{t('action.cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving} className="bg-emerald-600 hover:bg-emerald-700">
{isSaving ? t('drawing.saving') : t('drawing.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
+200 -3
View File
@@ -15,6 +15,7 @@ import {
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
Strikethrough, Type, Highlighter, GripVertical,
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
Users as UsersIcon, Radio, MousePointer2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -35,6 +36,7 @@ import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import DrawingCanvas from '@/components/drawing-canvas';
import { useNotebookCollaboration, type CursorData, type EditData } from '@/lib/websocket';
// ─── Types ───────────────────────────────────────────────────────────
@@ -1028,6 +1030,9 @@ function NotebookDetailView({
const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
// Collaboration state
const [collabActivityOpen, setCollabActivityOpen] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedContentRef = useRef<string>('');
@@ -1037,6 +1042,69 @@ function NotebookDetailView({
const pages = notebook.pages ?? [];
const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null;
// ─── Collaboration Hook ─────────────────────────────────────────────
const {
onlineUsers,
cursors,
activities,
lastEdit,
editingUsers,
broadcastCursor,
broadcastEdit,
broadcastActivity,
addEditListener,
} = useNotebookCollaboration(notebook.id);
// Handle incoming edits from other users (last-write-wins with visual indicator)
const collabEditTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [collabEditIndicator, setCollabEditIndicator] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = addEditListener((edit: EditData) => {
// If editing the same page, apply the incoming edit
if (currentPage && edit.pageId === currentPage.id && editorRef.current) {
isInternalChange.current = true;
editorRef.current.innerHTML = edit.content;
setPageContent(edit.content);
lastSavedContentRef.current = edit.content;
setTimeout(() => { isInternalChange.current = false; }, 100);
}
// Show editing indicator
setCollabEditIndicator(t('collab.editing_by', { name: edit.userName }));
if (collabEditTimeoutRef.current) clearTimeout(collabEditTimeoutRef.current);
collabEditTimeoutRef.current = setTimeout(() => setCollabEditIndicator(null), 3000);
});
return unsubscribe;
}, [addEditListener, currentPage]);
// Broadcast cursor position on mouse move
const cursorThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleEditorMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!currentPage) return;
if (cursorThrottleRef.current) return;
cursorThrottleRef.current = setTimeout(() => {
cursorThrottleRef.current = null;
}, 200);
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * 100);
const y = Math.round(((e.clientY - rect.top) / rect.height) * 100);
broadcastCursor(currentPage.id, x, y);
}, [currentPage, broadcastCursor]);
// Broadcast edit on content change (debounced)
const broadcastEditTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Broadcast page change activity
useEffect(() => {
if (currentPage) {
broadcastActivity(currentPage.id, currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`);
}
}, [currentPageId, currentPage, broadcastActivity]);
// ─── End Collaboration ──────────────────────────────────────────────
// Set content into editor ref without triggering auto-save
const setEditorContent = useCallback((html: string) => {
isInternalChange.current = true;
@@ -1079,16 +1147,30 @@ function NotebookDetailView({
if (editorRef.current) {
const html = editorRef.current.innerHTML;
setPageContent(html);
// Broadcast edit to collaborators (debounced)
if (broadcastEditTimerRef.current) clearTimeout(broadcastEditTimerRef.current);
broadcastEditTimerRef.current = setTimeout(() => {
if (currentPage) {
broadcastEdit(currentPage.id, html);
}
}, 500);
}
}, []);
}, [currentPage, broadcastEdit]);
// Handle format change from toolbar (for active state refresh)
const handleFormatChange = useCallback(() => {
if (editorRef.current) {
const html = editorRef.current.innerHTML;
setPageContent(html);
// Broadcast edit to collaborators (debounced)
if (broadcastEditTimerRef.current) clearTimeout(broadcastEditTimerRef.current);
broadcastEditTimerRef.current = setTimeout(() => {
if (currentPage) {
broadcastEdit(currentPage.id, html);
}
}, 500);
}
}, []);
}, [currentPage, broadcastEdit]);
// Auto-save with 3 second debounce
const triggerAutoSave = useCallback(() => {
@@ -1375,6 +1457,81 @@ function NotebookDetailView({
</div>
)}
{/* Collaboration: Online Users Indicator */}
{onlineUsers.length > 0 && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200/50 dark:border-emerald-800/30 shrink-0">
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse" />
<div className="flex -space-x-1.5">
{onlineUsers.slice(0, 4).map((u) => (
<div
key={u.userId}
className="w-6 h-6 rounded-full border-2 border-white dark:border-gray-900 flex items-center justify-center text-[8px] font-bold text-white"
style={{ backgroundColor: u.color }}
title={u.userName}
>
{u.userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
))}
</div>
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-300">
{onlineUsers.length}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{t('collab.online_users', { count: onlineUsers.length })}: {onlineUsers.map(u => u.userName).join(', ')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Collaboration: Activity Feed Toggle */}
{onlineUsers.length > 0 && (
<Popover open={collabActivityOpen} onOpenChange={setCollabActivityOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0 relative">
<UsersIcon className="w-4 h-4" />
{activities.length > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-amber-400 text-white text-[8px] font-bold flex items-center justify-center">
{activities.length > 9 ? '9+' : activities.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0 rounded-xl border-emerald-200/60 dark:border-emerald-900/40">
<div className="px-3 py-2 border-b border-emerald-100/50 dark:border-emerald-900/30 bg-gradient-to-r from-emerald-50/50 to-teal-50/30 dark:from-emerald-950/20 dark:to-teal-950/10">
<h3 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
<UsersIcon className="w-3.5 h-3.5" />
{t('collab.activity_title')}
</h3>
</div>
<ScrollArea className="max-h-60">
{activities.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-500 dark:text-gray-400">
{t('collab.no_activity')}
</div>
) : (
<div className="p-2 space-y-1">
{activities.map((a, i) => (
<div key={`${a.userId}-${a.timestamp}-${i}`} className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-gray-600 dark:text-gray-400">
<div className="w-5 h-5 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-[8px] font-bold text-emerald-700 dark:text-emerald-300 shrink-0">
{a.userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<span className="truncate">
<strong>{a.userName}</strong> {t('collab.started_editing')} {a.pageTitle}
</span>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
)}
{/* Drawing mode toggle */}
<Button
variant={drawingMode ? 'default' : 'outline'}
@@ -1519,6 +1676,19 @@ function NotebookDetailView({
className="font-semibold text-lg border-0 bg-transparent shadow-none focus-visible:ring-0 h-10"
/>
{/* Collaboration: Editing indicator */}
{collabEditIndicator && (
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200/50 dark:border-amber-800/30 shrink-0"
>
<MousePointer2 className="w-3.5 h-3.5 text-amber-500" />
<span className="text-xs text-amber-700 dark:text-amber-300 font-medium">{collabEditIndicator}</span>
</motion.div>
)}
{/* Version history button in page title bar */}
<Button
variant="ghost"
@@ -1572,7 +1742,7 @@ function NotebookDetailView({
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
{/* Page content area */}
<div className="flex-1 overflow-hidden p-4">
<div className="flex-1 overflow-hidden p-4 relative">
<div
className="w-full h-full max-w-3xl mx-auto rounded-xl overflow-hidden"
style={{ backgroundColor: '#fff' }}
@@ -1581,6 +1751,7 @@ function NotebookDetailView({
<div
className="min-h-full p-6"
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
onMouseMove={handleEditorMouseMove}
>
<div
ref={editorRef}
@@ -1594,6 +1765,32 @@ function NotebookDetailView({
</div>
</ScrollArea>
</div>
{/* Collaboration: Cursor overlays */}
{cursors.filter(c => c.pageId === currentPage?.id).map((cursor) => (
<motion.div
key={cursor.userId}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute pointer-events-none z-20"
style={{
left: `${cursor.x}%`,
top: `${cursor.y}%`,
transform: 'translate(-4px, -4px)',
}}
>
<MousePointer2
className="w-4 h-4"
style={{ color: cursor.color, fill: cursor.color }}
/>
<div
className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap mt-0.5"
style={{ backgroundColor: cursor.color }}
>
{cursor.userName}
</div>
</motion.div>
))}
</div>
</div>
)}
+528 -18
View File
@@ -19,6 +19,12 @@ import {
ChevronUp,
X,
GripVertical,
BarChart3,
GraduationCap,
FileDown,
LayoutTemplate,
CheckCircle2,
Calculator,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -28,6 +34,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Progress } from '@/components/ui/progress';
import {
Select,
SelectContent,
@@ -63,12 +70,18 @@ import {
deleteRubric,
duplicateRubric,
fetchSubjects,
fetchRubricTemplates,
fetchRubricAnalytics,
gradeWithRubric,
type Rubric,
type RubricCriterion,
type RubricLevel,
type RubricCriterionInput,
type RubricLevelInput,
type Subject,
type RubricTemplate,
type RubricAnalytics,
type RubricGradeResult,
} from '@/lib/api';
import { toast } from 'sonner';
@@ -169,6 +182,23 @@ export default function RubricLibraryView() {
const [saving, setSaving] = useState(false);
const [formTab, setFormTab] = useState('edit');
// Template dialog
const [templateOpen, setTemplateOpen] = useState(false);
const [templates, setTemplates] = useState<RubricTemplate[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
// Analytics dialog
const [analyticsOpen, setAnalyticsOpen] = useState(false);
const [analyticsData, setAnalyticsData] = useState<RubricAnalytics | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
// Grading dialog
const [gradeOpen, setGradeOpen] = useState(false);
const [gradingRubric, setGradingRubric] = useState<Rubric | null>(null);
const [gradeScores, setGradeScores] = useState<Record<string, { levelId: string; points: number }>>({});
const [gradeResult, setGradeResult] = useState<RubricGradeResult | null>(null);
const [gradingCalc, setGradingCalc] = useState(false);
// Form state
const [formTitle, setFormTitle] = useState('');
const [formDescription, setFormDescription] = useState('');
@@ -255,6 +285,49 @@ export default function RubricLibraryView() {
setFormOpen(true);
};
const openTemplateDialog = async () => {
setTemplateOpen(true);
setTemplatesLoading(true);
try {
const data = await fetchRubricTemplates();
setTemplates(data);
} catch {
toast.error(t('rubrics.error'));
} finally {
setTemplatesLoading(false);
}
};
const applyTemplate = (template: RubricTemplate) => {
setEditingRubric(null);
setFormTitle(template.title);
setFormDescription(template.description);
setFormType(template.type);
setFormMaxPoints(template.maxPoints);
setFormIsPublic(false);
// Find matching subject
const matchingSubject = subjects.find((s) => s.name === template.subject);
setFormSubjectId(matchingSubject?.id ?? '');
setFormCriteria(
template.criteria.map((c, ci) => ({
name: c.name,
description: c.description,
weight: c.weight,
maxPoints: c.maxPoints,
order: ci,
levels: c.levels.map((l, li) => ({
label: l.label,
description: l.description,
points: l.points,
order: li,
})),
}))
);
setFormTab('edit');
setTemplateOpen(false);
setFormOpen(true);
};
const addCriterion = () => {
setFormCriteria((prev) => [...prev, emptyCriterion(prev.length)]);
};
@@ -401,6 +474,63 @@ export default function RubricLibraryView() {
window.print();
};
/* ── Analytics ──────────────────────────────────────────────────── */
const openAnalytics = async (r: Rubric) => {
setAnalyticsOpen(true);
setAnalyticsLoading(true);
setAnalyticsData(null);
try {
const data = await fetchRubricAnalytics(r.id);
setAnalyticsData(data);
} catch {
toast.error(t('rubrics.error'));
} finally {
setAnalyticsLoading(false);
}
};
/* ── Grading ────────────────────────────────────────────────────── */
const openGrading = (r: Rubric) => {
setGradingRubric(r);
setGradeScores({});
setGradeResult(null);
setGradeOpen(true);
};
const selectLevel = (criterionId: string, levelId: string, points: number) => {
setGradeScores((prev) => ({ ...prev, [criterionId]: { levelId, points } }));
setGradeResult(null); // Reset result when scores change
};
const autoCalculateGrade = () => {
if (!gradingRubric) return;
const scores = Object.entries(gradeScores).map(([criterionId, data]) => ({
criterionId,
levelId: data.levelId,
points: data.points,
}));
if (scores.length < gradingRubric.criteria.length) {
toast.error(t('rubrics.select_level'));
return;
}
setGradingCalc(true);
gradeWithRubric(gradingRubric.id, {
studentId: 'preview',
scores,
})
.then((result) => {
setGradeResult(result);
})
.catch(() => {
toast.error(t('rubrics.error'));
})
.finally(() => {
setGradingCalc(false);
});
};
/* ── Render ───────────────────────────────────────────────────── */
if (loading) {
@@ -430,13 +560,23 @@ export default function RubricLibraryView() {
<p className="text-sm text-muted-foreground">{t('rubrics.subtitle')}</p>
</div>
</div>
<Button
onClick={openCreateForm}
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white shadow-md"
>
<Plus className="h-4 w-4 mr-2" />
{t('rubrics.create')}
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={openTemplateDialog}
className="border-emerald-300 dark:border-emerald-700"
>
<LayoutTemplate className="h-4 w-4 mr-2" />
{t('rubrics.templates')}
</Button>
<Button
onClick={openCreateForm}
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white shadow-md"
>
<Plus className="h-4 w-4 mr-2" />
{t('rubrics.create')}
</Button>
</div>
</motion.div>
{/* Filters */}
@@ -491,13 +631,22 @@ export default function RubricLibraryView() {
</div>
<h3 className="text-lg font-semibold mb-2">{t('rubrics.empty_title')}</h3>
<p className="text-muted-foreground max-w-md mb-6">{t('rubrics.empty_desc')}</p>
<Button
onClick={openCreateForm}
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('rubrics.empty_create')}
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={openTemplateDialog}
>
<LayoutTemplate className="h-4 w-4 mr-2" />
{t('rubrics.templates')}
</Button>
<Button
onClick={openCreateForm}
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
>
<Plus className="h-4 w-4 mr-2" />
{t('rubrics.empty_create')}
</Button>
</div>
</motion.div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -578,6 +727,30 @@ export default function RubricLibraryView() {
: ''}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
openGrading(rubric);
}}
className="h-8 w-8 p-0"
title={t('rubrics.grade')}
>
<GraduationCap className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
openAnalytics(rubric);
}}
className="h-8 w-8 p-0"
title={t('rubrics.analytics')}
>
<BarChart3 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -622,6 +795,84 @@ export default function RubricLibraryView() {
</div>
)}
{/* ── Template Dialog ─────────────────────────────────────────── */}
<Dialog open={templateOpen} onOpenChange={setTemplateOpen}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5 text-emerald-500" />
{t('rubrics.templates_title')}
</DialogTitle>
<DialogDescription>{t('rubrics.templates_desc')}</DialogDescription>
</DialogHeader>
{templatesLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
) : (
<div className="space-y-3">
{templates.map((template) => (
<Card
key={template.id}
className="hover:shadow-md transition-shadow cursor-pointer border-l-4 border-l-teal-500"
onClick={() => applyTemplate(template)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-sm font-semibold">{template.title}</CardTitle>
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{template.subject}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-2 mb-2">
<Badge variant="outline" className="text-xs">
{template.criteria.length} {t('rubrics.criteria')}
</Badge>
<Badge variant="outline" className="text-xs">
{template.maxPoints} {t('rubrics.points')}
</Badge>
</div>
<div className="flex flex-wrap gap-1">
{template.criteria.map((c) => (
<span
key={c.name}
className="text-xs bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 px-2 py-0.5 rounded"
>
{c.name}
</span>
))}
</div>
<Button
size="sm"
className="mt-3 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
onClick={(e) => {
e.stopPropagation();
applyTemplate(template);
}}
>
{t('rubrics.use_template')}
</Button>
</CardContent>
</Card>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={openCreateForm}>
<Plus className="h-4 w-4 mr-2" />
{t('rubrics.no_template')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Create/Edit Dialog ─────────────────────────────────────── */}
<Dialog open={formOpen} onOpenChange={(open) => {
if (!open && saving) return;
@@ -900,10 +1151,20 @@ export default function RubricLibraryView() {
{detailRubric.description}
</DialogDescription>
</div>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
{t('rubrics.print')}
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openGrading(detailRubric)}>
<GraduationCap className="h-4 w-4 mr-1" />
{t('rubrics.grade')}
</Button>
<Button variant="outline" size="sm" onClick={() => openAnalytics(detailRubric)}>
<BarChart3 className="h-4 w-4 mr-1" />
{t('rubrics.analytics')}
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
{t('rubrics.print')}
</Button>
</div>
</div>
</DialogHeader>
@@ -960,6 +1221,255 @@ export default function RubricLibraryView() {
</DialogContent>
</Dialog>
{/* ── Grading Dialog ─────────────────────────────────────────── */}
<Dialog open={gradeOpen} onOpenChange={setGradeOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-emerald-500" />
{t('rubrics.grade_title')}
</DialogTitle>
<DialogDescription>
{gradingRubric?.title}
</DialogDescription>
</DialogHeader>
{gradingRubric && (
<div className="space-y-4">
{/* Score Sheet */}
<div className="space-y-3">
{gradingRubric.criteria.map((criterion) => (
<Card key={criterion.id} className="border-l-4 border-l-teal-500">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm font-semibold">{criterion.name}</CardTitle>
{criterion.description && (
<p className="text-xs text-muted-foreground mt-0.5">{criterion.description}</p>
)}
</div>
<Badge variant="outline" className="text-xs">
{criterion.maxPoints} {t('rubrics.points')}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{criterion.levels.map((level) => {
const isSelected = gradeScores[criterion.id]?.levelId === level.id;
return (
<motion.button
key={level.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => selectLevel(criterion.id, level.id, level.points)}
className={`text-left p-3 rounded-lg border-2 transition-all ${
isSelected
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20 shadow-md'
: 'border-gray-200 dark:border-gray-700 hover:border-emerald-300'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm">{level.label}</span>
<Badge
variant="outline"
className={`text-xs ${isSelected ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300' : ''}`}
>
{level.points} {t('rubrics.points')}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{level.description}</p>
{isSelected && (
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-1" />
)}
</motion.button>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
{/* Auto-Grade Button */}
<div className="flex items-center gap-3">
<Button
onClick={autoCalculateGrade}
disabled={gradingCalc || Object.keys(gradeScores).length < (gradingRubric.criteria.length)}
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white"
>
{gradingCalc ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Calculator className="h-4 w-4 mr-2" />
)}
{t('rubrics.auto_grade')}
</Button>
<span className="text-xs text-muted-foreground">
{Object.keys(gradeScores).length}/{gradingRubric.criteria.length} {t('rubrics.criteria')}
</span>
</div>
{/* Grade Result */}
{gradeResult && (
<Card className="border-2 border-emerald-300 dark:border-emerald-700 bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/20 dark:to-teal-950/20">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t('rubrics.auto_grade')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center">
<div className="text-2xl font-bold text-emerald-600">{gradeResult.overallPercentage}%</div>
<div className="text-xs text-muted-foreground">{t('rubrics.percentage')}</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-teal-600">{gradeResult.finalScore}/{gradeResult.maxPoints}</div>
<div className="text-xs text-muted-foreground">{t('rubrics.final_score')}</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-amber-600">{gradeResult.grade} — {gradeResult.gradeLabel}</div>
<div className="text-xs text-muted-foreground">{t('rubrics.grade_result')}</div>
</div>
</div>
<Separator className="my-3" />
<div className="space-y-2">
<h4 className="text-xs font-semibold">{t('rubrics.criterion_breakdown')}</h4>
{gradeResult.criterionBreakdown.map((cb) => (
<div key={cb.criterionId} className="flex items-center gap-3">
<span className="text-xs font-medium w-36 truncate">{cb.criterionName}</span>
<Progress value={cb.percentage} className="flex-1 h-2" />
<span className="text-xs text-muted-foreground w-16 text-right">
{cb.earnedPoints}/{cb.maxPoints}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)}
</DialogContent>
</Dialog>
{/* ── Analytics Dialog ────────────────────────────────────────── */}
<Dialog open={analyticsOpen} onOpenChange={setAnalyticsOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-emerald-500" />
{t('rubrics.analytics_title')}
</DialogTitle>
<DialogDescription>
{analyticsData?.rubricTitle}
</DialogDescription>
</DialogHeader>
{analyticsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
) : analyticsData ? (
<div className="space-y-6">
{/* Overall Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card className="border-2 border-emerald-200 dark:border-emerald-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-emerald-600">{analyticsData.overallStats.averagePercentage}%</div>
<div className="text-xs text-muted-foreground">{t('rubrics.average_score')}</div>
</CardContent>
</Card>
<Card className="border-2 border-teal-200 dark:border-teal-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-teal-600">{analyticsData.overallStats.totalAssessments}</div>
<div className="text-xs text-muted-foreground">{t('rubrics.class_performance')}</div>
</CardContent>
</Card>
<Card className="border-2 border-amber-200 dark:border-amber-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-amber-600">{analyticsData.overallStats.totalStudents}</div>
<div className="text-xs text-muted-foreground">{t('rubrics.percentage')}</div>
</CardContent>
</Card>
<Card className="border-2 border-rose-200 dark:border-rose-800">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-rose-600">{analyticsData.overallStats.averageScore}/{analyticsData.overallStats.totalMaxPoints}</div>
<div className="text-xs text-muted-foreground">{t('rubrics.final_score')}</div>
</CardContent>
</Card>
</div>
{/* Class Performance by Criterion */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('rubrics.class_performance')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analyticsData.criteriaAnalytics.map((ca) => (
<div key={ca.criterionId} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{ca.criterionName}</span>
<span className="text-xs text-muted-foreground">
{t('rubrics.average_score')}: {ca.classAverage}/{ca.maxPoints}
</span>
</div>
<div className="flex items-center gap-2">
<Progress
value={ca.maxPoints > 0 ? (ca.classAverage / ca.maxPoints) * 100 : 0}
className="flex-1 h-2"
/>
<span className="text-xs font-medium">
{ca.maxPoints > 0 ? Math.round((ca.classAverage / ca.maxPoints) * 100) : 0}%
</span>
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
<span>{t('rubrics.highest_score')}: {ca.highestScore}</span>
<span>{t('rubrics.lowest_score')}: {ca.lowestScore}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Grade Distribution */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t('rubrics.grade_distribution')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{analyticsData.gradeDistribution.map((gd) => (
<div key={gd.grade} className="flex items-center gap-3">
<span className="text-sm font-medium w-28">{gd.grade}</span>
<div className="flex-1 bg-gray-100 dark:bg-gray-800 rounded-full h-4 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${analyticsData.overallStats.averagePercentage}%`,
backgroundColor: gd.color,
}}
/>
</div>
<span className="text-xs text-muted-foreground w-16">{gd.range}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
{t('rubrics.error')}
</div>
)}
</DialogContent>
</Dialog>
{/* ── Delete Confirmation ────────────────────────────────────── */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
+353 -1
View File
@@ -40,6 +40,12 @@ import {
Loader2,
ClipboardCheck,
CalendarDays,
HardDrive,
Archive,
RotateCcw,
Volume2,
VolumeX,
Bell,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -76,10 +82,12 @@ import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import { getNotificationSoundPref, setNotificationSoundPref, playNotificationSound } from '@/lib/websocket';
import {
fetchSchools,
@@ -132,6 +140,41 @@ const actionColors: Record<string, string> = {
EXPORT: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
};
// ─── Notification Sound Setting Component ─────────────────────────────
function NotificationSoundSetting() {
const [enabled, setEnabled] = useState(getNotificationSoundPref());
const handleToggle = () => {
const newValue = !enabled;
setEnabled(newValue);
setNotificationSoundPref(newValue);
if (newValue) {
playNotificationSound();
}
toast.success(newValue ? t('settings.notification_sound_enabled') : t('settings.notification_sound_disabled'));
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
{enabled ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{t('settings.notification_sound')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('settings.notification_sound_desc')}
</p>
</div>
</div>
<Switch checked={enabled} onCheckedChange={handleToggle} />
</div>
);
}
export default function SettingsView() {
const currentUser = useAppStore((s) => s.currentUser);
const [activeTab, setActiveTab] = useState('school');
@@ -233,6 +276,24 @@ export default function SettingsView() {
// Export history
const [exportHistory, setExportHistory] = useState<Array<{ type: string; date: string }>>([]);
// Backup state
const [backups, setBackups] = useState<Array<{
id: string;
schoolId: string;
filename: string;
size: number;
type: string;
status: string;
notes: string | null;
createdAt: string;
}>>([]);
const [backupLoading, setBackupLoading] = useState(false);
const [backupCreating, setBackupCreating] = useState(false);
const [deleteBackupId, setDeleteBackupId] = useState<string | null>(null);
const [restoreBackupId, setRestoreBackupId] = useState<string | null>(null);
const [autoBackupEnabled, setAutoBackupEnabled] = useState(false);
const [autoBackupFrequency, setAutoBackupFrequency] = useState<'daily' | 'weekly'>('weekly');
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const loadSchools = useCallback(async () => {
@@ -338,13 +399,106 @@ export default function SettingsView() {
}
}, []);
// Backup functions
const loadBackups = useCallback(async () => {
if (!currentUser?.schoolId) return;
setBackupLoading(true);
try {
const response = await fetch(`/api/backup?schoolId=${currentUser.schoolId}`);
if (!response.ok) throw new Error('Failed to load backups');
const data = await response.json();
setBackups(Array.isArray(data) ? data : []);
} catch (err) {
console.error('Failed to load backups:', err);
setBackups([]);
} finally {
setBackupLoading(false);
}
}, [currentUser?.schoolId]);
// Reload demo accounts when demo tab is selected
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab);
if (tab === 'demo' && isAdmin) {
loadDemoAccounts();
}
}, [isAdmin, loadDemoAccounts]);
if (tab === 'backup') {
loadBackups();
}
}, [isAdmin, loadDemoAccounts, loadBackups]);
const handleCreateBackup = useCallback(async () => {
if (!currentUser?.schoolId) return;
setBackupCreating(true);
try {
const response = await fetch('/api/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schoolId: currentUser.schoolId }),
});
if (!response.ok) throw new Error('Failed to create backup');
toast.success(t('backup.created_success'));
loadBackups();
} catch (err) {
console.error('Failed to create backup:', err);
toast.error(t('backup.error_create'));
} finally {
setBackupCreating(false);
}
}, [currentUser?.schoolId, loadBackups]);
const handleRestoreBackup = useCallback(async () => {
if (!restoreBackupId || !currentUser?.schoolId) return;
try {
const response = await fetch('/api/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schoolId: currentUser.schoolId, action: 'restore', backupId: restoreBackupId }),
});
if (!response.ok) throw new Error('Failed to restore backup');
toast.success(t('backup.restored_success'));
setRestoreBackupId(null);
} catch (err) {
console.error('Failed to restore backup:', err);
toast.error(t('backup.error_restore'));
}
}, [restoreBackupId, currentUser?.schoolId]);
const handleDeleteBackup = useCallback(async (id: string) => {
try {
const response = await fetch(`/api/backup?id=${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete backup');
toast.success(t('backup.deleted_success'));
setDeleteBackupId(null);
loadBackups();
} catch (err) {
console.error('Failed to delete backup:', err);
toast.error(t('backup.error_delete'));
}
}, [loadBackups]);
const handleDownloadBackup = useCallback(async (backup: typeof backups[0]) => {
if (!currentUser?.schoolId) return;
try {
const response = await fetch('/api/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schoolId: currentUser.schoolId }),
});
if (!response.ok) throw new Error('Failed to download backup');
const data = await response.json();
const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = backup.filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download backup:', err);
toast.error(t('backup.error_create'));
}
}, [currentUser?.schoolId]);
useEffect(() => {
loadSchools();
@@ -804,6 +958,10 @@ export default function SettingsView() {
<span className="hidden sm:inline">{t('settings.demo_accounts')}</span>
</TabsTrigger>
)}
<TabsTrigger value="backup" className="rounded-lg min-h-[44px] data-[state=active]:bg-teal-500 data-[state=active]:text-white">
<HardDrive className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('backup.title')}</span>
</TabsTrigger>
</TabsList>
{/* ── School Info Tab ─────────────────────────────────── */}
@@ -892,6 +1050,11 @@ export default function SettingsView() {
</div>
)}
</CardContent>
{/* Notification Sound Setting */}
<div className="px-6 pb-4 border-t border-gray-100 dark:border-gray-800 pt-4">
<NotificationSoundSetting />
</div>
</Card>
</TabsContent>
@@ -2169,8 +2332,197 @@ export default function SettingsView() {
</Card>
</TabsContent>
)}
{/* ── Backup Tab ─────────────────────────────────────────── */}
<TabsContent value="backup">
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<HardDrive className="h-4 w-4" />
</div>
{t('backup.title')}
</CardTitle>
<CardDescription>{t('backup.auto')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Create backup + Auto-backup toggle */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<Button
onClick={handleCreateBackup}
disabled={backupCreating}
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white shadow-md rounded-xl px-6 min-h-[44px]"
>
{backupCreating ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Archive className="h-4 w-4 mr-2" />
)}
{backupCreating ? t('backup.creating') : t('backup.create')}
</Button>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoBackupEnabled}
onCheckedChange={setAutoBackupEnabled}
className="data-[state=checked]:bg-teal-500"
/>
<Label className="text-sm text-gray-600 dark:text-gray-400">{t('backup.auto')}</Label>
</div>
{autoBackupEnabled && (
<Select value={autoBackupFrequency} onValueChange={(v) => setAutoBackupFrequency(v as 'daily' | 'weekly')}>
<SelectTrigger className="w-32 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t('backup.daily')}</SelectItem>
<SelectItem value="weekly">{t('backup.weekly')}</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
{/* Last backup info */}
{backups.length > 0 && backups[0].status === 'completed' && (
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Clock className="h-4 w-4" />
{t('backup.last')}: {new Date(backups[0].createdAt).toLocaleString()}
</div>
)}
{/* Backup list */}
{backupLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-xl" />)}
</div>
) : backups.length === 0 ? (
<div className="text-center py-8">
<HardDrive className="h-8 w-8 text-teal-400 dark:text-teal-500 mx-auto mb-2" />
<p className="text-gray-500 dark:text-gray-400">{t('backup.no_backups')}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">{t('backup.no_backups_desc')}</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto scrollbar-education">
{backups.map((backup) => {
const sizeKB = Math.round(backup.size / 1024);
const sizeMB = (backup.size / (1024 * 1024)).toFixed(1);
const sizeDisplay = backup.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
const statusColor = backup.status === 'completed'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: backup.status === 'failed'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
const statusText = backup.status === 'completed'
? t('backup.completed')
: backup.status === 'failed'
? t('backup.failed')
: t('backup.pending');
return (
<motion.div
key={backup.id}
whileHover={{ scale: 1.01 }}
className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 border-l-teal-400/40 hover:border-l-teal-500 transition-colors"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 shrink-0">
<Archive className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">{backup.filename}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(backup.createdAt).toLocaleString()}
</span>
<Badge className={`${statusColor} text-[10px] px-1.5 py-0`}>
{statusText}
</Badge>
<Badge className="bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400 text-[10px] px-1.5 py-0">
{backup.type === 'full' ? t('backup.type_full') : t('backup.type_incremental')}
</Badge>
<span className="text-xs text-gray-500 dark:text-gray-400">{t('backup.size')}: {sizeDisplay}</span>
</div>
{backup.notes && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 truncate">{backup.notes}</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadBackup(backup)}
className="h-8 w-8 text-gray-400 hover:text-teal-500 hover:bg-teal-50 dark:hover:bg-teal-900/20"
aria-label={t('backup.download')}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setRestoreBackupId(backup.id)}
className="h-8 w-8 text-gray-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20"
aria-label={t('backup.restore')}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteBackupId(backup.id)}
className="h-8 w-8 text-gray-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/20"
aria-label={t('backup.delete')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
{/* ── Backup Restore Confirmation ──────────────────────────── */}
<AlertDialog open={!!restoreBackupId} onOpenChange={(open) => { if (!open) setRestoreBackupId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('backup.restore_confirm')}</AlertDialogTitle>
<AlertDialogDescription>{t('backup.restore_confirm_desc')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleRestoreBackup} className="bg-amber-600 hover:bg-amber-700 text-white">
<RotateCcw className="h-4 w-4 mr-1.5" />
{t('backup.restore')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Backup Delete Confirmation ───────────────────────────── */}
<AlertDialog open={!!deleteBackupId} onOpenChange={(open) => { if (!open) setDeleteBackupId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('backup.delete_confirm')}</AlertDialogTitle>
<AlertDialogDescription>{t('backup.delete_confirm_desc')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteBackupId && handleDeleteBackup(deleteBackupId)} className="bg-rose-600 hover:bg-rose-700 text-white">
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
);
}