Round 12: WebSocket Collaboration, Curriculum Mapping, Attendance Analytics, Data Backup, Mobile Optimization, Performance
This commit is contained in:
1 parent
21310d3f62
commit
ba55bd13e4
24 files changed
+6067
-947
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user