Round 10: WYSIWYG Editor, Notification System, Calendar Events, Student UI, Drag-Reorder, Styling

This commit is contained in:
Mustafa Cetin committed 2026-07-29 08:49:40 +00:00
1 parent 62f4a150e0
commit b90ad85b91
18 files changed
+3105 -626

No files matched your search

+213 -130
View File
@@ -42,6 +42,16 @@ import {
Plus,
Palette,
Leaf,
Check,
CheckCircle2,
AlertTriangle,
Info,
BookMarked,
StickyNote,
Award,
UserCheck,
BarChart3,
Trash2,
} from 'lucide-react';
import {
Sidebar,
@@ -90,7 +100,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAppStore, type ViewName } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchSchoolYears, fetchStudents, fetchClasses, fetchNotifications, markServerNotificationsRead, type SchoolYear, type Student, type ClassGroup, type NotificationData, type AssessmentNotification, type MissingObservationNotification } from '@/lib/api';
import { fetchSchoolYears, fetchStudents, fetchClasses, fetchDBNotifications, markAllNotificationsRead, markSingleNotificationRead, type SchoolYear, type Student, type ClassGroup, type DBNotification, type DBNotificationData } from '@/lib/api';
import OnboardingTour, { isOnboardingCompleted } from '@/components/onboarding-tour';
import KeyboardShortcutsDialog from '@/components/keyboard-shortcuts-dialog';
import {
@@ -104,7 +114,6 @@ import {
CommandShortcut,
} from '@/components/ui/command';
import { toast } from 'sonner';
import { AlertTriangle, Info, ArrowRight } from 'lucide-react';
import DashboardView from './dashboard-view';
import ClassesView from './classes-view';
@@ -174,6 +183,22 @@ const navSections: NavSection[] = [
},
];
// Student-specific navigation (simplified)
const studentNavSections: NavSection[] = [
{
id: 'student-main',
labelKey: 'student.dashboard_title',
items: [
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.student_notebooks' },
{ key: 'flower', icon: Flower2, labelKey: 'nav.student_competencies' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
],
},
];
function renderView(view: ViewName) {
switch (view) {
case 'dashboard': return <DashboardView />;
@@ -225,7 +250,7 @@ export default function AppLayout() {
const [searchLoading, setSearchLoading] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [notifData, setNotifData] = useState<NotificationData | null>(null);
const [notifData, setNotifData] = useState<DBNotificationData | null>(null);
const [notifLoading, setNotifLoading] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -239,7 +264,10 @@ export default function AppLayout() {
? `${currentUser.firstName} ${currentUser.lastName}`
: '';
const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : 'role.super_admin';
const roleKey = currentUser?.role === 'TEACHER' ? 'role.teacher' : currentUser?.role === 'SCHOOL_ADMIN' ? 'role.school_admin' : currentUser?.role === 'STUDENT' ? 'role.student' : 'role.super_admin';
const isStudent = currentUser?.role === 'STUDENT';
const activeNavSections = isStudent ? studentNavSections : navSections;
const isDemoUser = currentUser?.isDemo === true || (currentUser?.email?.endsWith('@competencetrack.org') && currentUser?.email?.startsWith('demo'));
@@ -441,7 +469,7 @@ export default function AppLayout() {
if (!currentUser?.schoolId) return;
setNotifLoading(true);
try {
const data = await fetchNotifications();
const data = await fetchDBNotifications();
setNotifData(data);
} catch {
// ignore
@@ -452,21 +480,18 @@ export default function AppLayout() {
useEffect(() => {
loadNotifications();
// Refresh notifications every 60 seconds
const interval = setInterval(loadNotifications, 60000);
return () => clearInterval(interval);
}, [loadNotifications]);
const handleMarkAllRead = useCallback(async () => {
if (!notifData) return;
const allIds = [
...notifData.upcomingAssessments.map((n) => n.id),
...notifData.missingObservations.map((n) => n.id),
];
if (allIds.length === 0) return;
if (!notifData || notifData.unreadCount === 0) return;
try {
await markServerNotificationsRead(allIds);
await markAllNotificationsRead();
setNotifData((prev) => prev ? {
...prev,
upcomingAssessments: prev.upcomingAssessments.map((n) => ({ ...n, isRead: true })),
missingObservations: prev.missingObservations.map((n) => ({ ...n, isRead: true })),
notifications: prev.notifications.map((n) => ({ ...n, isRead: true })),
unreadCount: 0,
} : prev);
} catch {
@@ -474,6 +499,87 @@ export default function AppLayout() {
}
}, [notifData]);
const handleMarkSingleRead = useCallback(async (id: string) => {
try {
await markSingleNotificationRead(id);
setNotifData((prev) => {
if (!prev) return prev;
const updated = prev.notifications.map((n) =>
n.id === id ? { ...n, isRead: true } : n
);
return {
...prev,
notifications: updated,
unreadCount: updated.filter((n) => !n.isRead).length,
};
});
} catch {
// ignore
}
}, []);
const handleNotificationClick = useCallback((n: DBNotification) => {
if (!n.isRead) {
handleMarkSingleRead(n.id);
}
setNotifOpen(false);
if (n.actionUrl) {
setCurrentView(n.actionUrl as ViewName);
}
}, [handleMarkSingleRead, setCurrentView]);
// Get icon and color for notification type
const getNotifTypeInfo = (type: string) => {
switch (type) {
case 'ASSESSMENT_DUE':
return { icon: AlertTriangle, bg: 'bg-amber-100/80 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', label: t('notifications.type_assessment_due') };
case 'MISSING_OBSERVATION':
return { icon: Info, bg: 'bg-teal-100/80 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400', label: t('notifications.type_missing_observation') };
case 'NOTEBOOK_SHARED':
return { icon: BookMarked, bg: 'bg-emerald-100/80 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400', label: t('notifications.type_notebook_shared') };
case 'BEHAVIOR_ALERT':
return { icon: Shield, bg: 'bg-rose-100/80 dark:bg-rose-900/20', text: 'text-rose-600 dark:text-rose-400', label: t('notifications.type_behavior_alert') };
case 'GRADE_COMPUTED':
return { icon: Award, bg: 'bg-violet-100/80 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400', label: t('notifications.type_grade_computed') };
case 'ATTENDANCE_ALERT':
return { icon: UserCheck, bg: 'bg-orange-100/80 dark:bg-orange-900/20', text: 'text-orange-600 dark:text-orange-400', label: t('notifications.type_attendance_alert') };
case 'REPORT_READY':
return { icon: FileText, bg: 'bg-cyan-100/80 dark:bg-cyan-900/20', text: 'text-cyan-600 dark:text-cyan-400', label: t('notifications.type_report_ready') };
default:
return { icon: Bell, bg: 'bg-gray-100/80 dark:bg-gray-900/20', text: 'text-gray-600 dark:text-gray-400', label: t('notifications.type_general') };
}
};
// Group notifications by date
const groupedNotifications = useMemo(() => {
if (!notifData?.notifications) return [];
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const weekAgo = new Date(today.getTime() - 7 * 86400000);
const groups: { label: string; notifications: DBNotification[] }[] = [];
const todayItems: DBNotification[] = [];
const yesterdayItems: DBNotification[] = [];
const weekItems: DBNotification[] = [];
const earlierItems: DBNotification[] = [];
for (const n of notifData.notifications) {
const date = new Date(n.createdAt);
if (date >= today) todayItems.push(n);
else if (date >= yesterday) yesterdayItems.push(n);
else if (date >= weekAgo) weekItems.push(n);
else earlierItems.push(n);
}
if (todayItems.length > 0) groups.push({ label: t('notifications.today'), notifications: todayItems });
if (yesterdayItems.length > 0) groups.push({ label: t('notifications.yesterday'), notifications: yesterdayItems });
if (weekItems.length > 0) groups.push({ label: t('notifications.this_week'), notifications: weekItems });
if (earlierItems.length > 0) groups.push({ label: t('notifications.earlier'), notifications: earlierItems });
return groups;
}, [notifData?.notifications]);
useEffect(() => {
async function loadYears() {
if (!currentUser?.schoolId) return;
@@ -515,12 +621,13 @@ export default function AppLayout() {
</SidebarHeader>
<SidebarContent>
{navSections.map((section) => (
{activeNavSections.map((section) => (
<SidebarGroup key={section.id}>
<SidebarGroupLabel className="text-emerald-600/70 dark:text-emerald-400/50 text-xs font-semibold uppercase tracking-wider inline-flex items-center gap-1.5">
{section.id === 'analysis' && <TrendingUp className="h-3 w-3" />}
{section.id === 'teaching' && <BookOpen className="h-3 w-3" />}
{section.id === 'setup' && <Settings className="h-3 w-3" />}
{section.id === 'student-main' && <GraduationCap className="h-3 w-3" />}
{t(section.labelKey)}
</SidebarGroupLabel>
<SidebarGroupContent>
@@ -711,7 +818,8 @@ export default function AppLayout() {
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
>
{notifData.unreadCount > 9 ? '9+' : notifData.unreadCount}
</motion.span>
@@ -720,142 +828,117 @@ export default function AppLayout() {
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[360px] p-0 rounded-xl border-emerald-200/60 dark:border-emerald-900/40 shadow-xl shadow-emerald-900/10"
className="w-[380px] p-0 rounded-xl border-emerald-200/60 dark:border-emerald-900/40 shadow-xl shadow-emerald-900/10"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 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">
<h3 className="text-sm font-semibold text-emerald-700 dark:text-emerald-300 flex items-center gap-1.5">
<Bell className="h-3.5 w-3.5" />
{t('notifications.title')}
</h3>
{notifData && notifData.unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllRead}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
{t('notifications.mark_all_read')}
</Button>
)}
<div className="flex items-center gap-1">
{notifData && notifData.unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllRead}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<CheckCircle2 className="h-3 w-3 mr-0.5" />
{t('notifications.mark_all_read')}
</Button>
)}
</div>
</div>
{/* Content */}
<ScrollArea className="max-h-[400px]">
<ScrollArea className="max-h-[420px]">
{notifLoading && (
<div className="px-4 py-8 text-center">
<Bell className="h-6 w-6 animate-pulse mx-auto mb-2 text-emerald-400" />
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/60">Loading…</p>
</div>
)}
{!notifLoading && notifData && notifData.upcomingAssessments.length === 0 && notifData.missingObservations.length === 0 && (
{!notifLoading && notifData && notifData.notifications.length === 0 && (
<div className="px-4 py-8 text-center">
<Bell className="h-8 w-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('notifications.empty')}</p>
</div>
)}
{!notifLoading && notifData && notifData.upcomingAssessments.length > 0 && (
<div className="px-2">
<p className="px-2 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-amber-600/70 dark:text-amber-400/60 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{t('notifications.upcoming_assessments')}
{!notifLoading && groupedNotifications.map((group) => (
<div key={group.label} className="px-2">
<p className="px-2 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-emerald-600/70 dark:text-emerald-400/60">
{group.label}
</p>
{notifData.upcomingAssessments.map((n: AssessmentNotification, i: number) => (
<motion.div
key={n.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className={`flex items-start gap-3 px-2 py-2 rounded-lg transition-colors hover:bg-amber-50/50 dark:hover:bg-amber-900/10 ${n.isRead ? 'opacity-60' : ''}`}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100/80 dark:bg-amber-900/20 shrink-0">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{n.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{n.description}</p>
<Badge variant="outline" className="mt-1 text-[10px] px-1.5 py-0 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200/50 dark:border-amber-900/30 text-amber-700 dark:text-amber-300">
{n.timestamp}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('assessments'); }}
className="h-7 text-xs shrink-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 flex items-center gap-1"
{group.notifications.map((n: DBNotification, i: number) => {
const typeInfo = getNotifTypeInfo(n.type);
const TypeIcon = typeInfo.icon;
return (
<motion.div
key={n.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.03 }}
onClick={() => handleNotificationClick(n)}
className={`flex items-start gap-3 px-2 py-2.5 rounded-lg transition-colors cursor-pointer min-h-[44px] hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 ${!n.isRead ? 'bg-emerald-50/30 dark:bg-emerald-900/5' : 'opacity-70'}`}
>
{t('notifications.to_assessment')}
<ArrowRight className="h-3 w-3" />
</Button>
</motion.div>
))}
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${typeInfo.bg} shrink-0`}>
<TypeIcon className={`h-4 w-4 ${typeInfo.text}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{n.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{n.message}</p>
<Badge variant="outline" className="mt-1 text-[10px] px-1.5 py-0 bg-emerald-50/50 dark:bg-emerald-900/10 border-emerald-200/50 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300">
{typeInfo.label}
</Badge>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{!n.isRead && (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleMarkSingleRead(n.id); }}
className="h-6 w-6 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
title={t('notifications.mark_read')}
>
<Check className="h-3 w-3" />
</Button>
)}
</div>
</motion.div>
);
})}
</div>
)}
{!notifLoading && notifData && notifData.missingObservations.length > 0 && (
<div className="px-2 border-t border-emerald-100/30 dark:border-emerald-900/20">
<p className="px-2 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-teal-600/70 dark:text-teal-400/60 flex items-center gap-1">
<Info className="h-3 w-3" />
{t('notifications.missing_observations')}
</p>
{notifData.missingObservations.map((n: MissingObservationNotification, i: number) => (
<motion.div
key={n.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className={`flex items-start gap-3 px-2 py-2 rounded-lg transition-colors hover:bg-teal-50/50 dark:hover:bg-teal-900/10 ${n.isRead ? 'opacity-60' : ''}`}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100/80 dark:bg-teal-900/20 shrink-0">
<Info className="h-4 w-4 text-teal-600 dark:text-teal-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{n.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{n.description}</p>
<Badge variant="outline" className="mt-1 text-[10px] px-1.5 py-0 bg-teal-50/50 dark:bg-teal-900/10 border-teal-200/50 dark:border-teal-900/30 text-teal-700 dark:text-teal-300">
{n.timestamp}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('progress'); }}
className="h-7 text-xs shrink-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 flex items-center gap-1"
>
{t('notifications.record_now')}
<ArrowRight className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
)}
{/* Quick Actions footer */}
{!notifLoading && notifData && (notifData.upcomingAssessments.length > 0 || notifData.missingObservations.length > 0) && (
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('progress'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<Pencil className="w-3 h-3 mr-0.5" /> {t('notifications.record_now')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('assessments'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<ClipboardCheck className="w-3 h-3 mr-0.5" /> {t('notifications.to_assessment')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('reports'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<FileText className="w-3 h-3 mr-0.5" /> {t('notifications.to_report')}
</Button>
</div>
)}
))}
</ScrollArea>
{/* Footer */}
{!notifLoading && notifData && notifData.notifications.length > 0 && (
<div className="px-3 py-2 border-t border-emerald-100/30 dark:border-emerald-900/20 flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('progress'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<Pencil className="w-3 h-3 mr-0.5" /> {t('notifications.record_now')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('assessments'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<ClipboardCheck className="w-3 h-3 mr-0.5" /> {t('notifications.to_assessment')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setNotifOpen(false); setCurrentView('reports'); }}
className="h-7 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
>
<FileText className="w-3 h-3 mr-0.5" /> {t('notifications.to_report')}
</Button>
</div>
)}
</PopoverContent>
</Popover>
{/* Language toggle (mobile: just flag) */}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -126,6 +126,7 @@ const masteryBarColor = (level: number) => {
export default function CompetenceFlowerView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const chartRef = useRef<HTMLDivElement>(null);
const [classes, setClasses] = useState<ClassGroup[]>([]);
@@ -496,7 +497,7 @@ export default function CompetenceFlowerView() {
{t('flower.color_scheme')}
{/* Preview swatch with current color */}
<div className="flex items-center gap-1.5 ml-1">
<div className="w-5 h-5 rounded-md ring-2 ring-offset-1 shadow-sm" style={{ backgroundColor: primaryColor, ringColor: primaryColor }} />
<div className="w-5 h-5 rounded-md ring-2 ring-offset-1 shadow-sm" style={{ backgroundColor: primaryColor, outlineColor: primaryColor }} />
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 font-normal ml-auto">
{colorPickerOpen ? '▼' : '▶'}
+245 -2
View File
@@ -41,6 +41,11 @@ import {
Palette,
Notebook,
CircleDot,
Flower2,
Calculator,
CalendarCheck,
Calendar as CalendarIconNav,
BookMarked,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -55,7 +60,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppStore } from '@/lib/store';
import { useAppStore, type ViewName } from '@/lib/store';
import { t } from '@/lib/i18n';
import { fetchDashboard, type DashboardData, getStoredNotifications, type AppNotification, addNotification, markNotificationsRead } from '@/lib/api';
@@ -271,6 +276,244 @@ export default function DashboardView() {
);
}
// ─── Student Dashboard ──────────────────────────────────────────────
if (currentUser?.role === 'STUDENT') {
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6 pb-8"
>
{/* Student Welcome Header */}
<motion.div variants={itemVariants}>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3.5">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/30 ring-2 ring-emerald-200/30 dark:ring-emerald-800/20">
<GraduationCap className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-500 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-300 bg-clip-text text-transparent">
{t('student.welcome_back')}, {currentUser.firstName}!
</h2>
<p className="text-emerald-600/60 dark:text-emerald-400/40 mt-0.5 text-sm">{t('student.progress_overview')}</p>
</div>
</div>
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/40 dark:border-emerald-900/30 text-xs font-medium text-emerald-700 dark:text-emerald-300 shadow-sm">
<CalendarDays className="h-3.5 w-3.5" />
<span className="font-semibold">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
</motion.div>
{/* Student Quick Stats */}
<motion.div variants={itemVariants}>
<div className="relative p-4 rounded-xl bg-mesh border border-emerald-200/40 dark:border-emerald-900/30 overflow-hidden backdrop-blur-sm">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-50/30 via-transparent to-teal-50/30 dark:from-emerald-900/10 dark:to-teal-900/5 pointer-events-none" />
<div className="relative flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<span className="inline-flex items-center gap-1.5 font-medium text-gray-700 dark:text-gray-300">
<Activity className="h-4 w-4 text-emerald-500" />
{t('student.progress_overview')}:
</span>
<span className="inline-flex items-center gap-1.5">
<PenLine className="h-3.5 w-3.5 text-amber-500" />
<span className="text-amber-600 dark:text-amber-400 font-semibold">{data.stats.totalProgressEntries}</span>
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.total_entries').toLowerCase()}</span>
</span>
{data.stats.totalAssessments > 0 && (
<span className="inline-flex items-center gap-1.5">
<ClipboardCheck className="h-3.5 w-3.5 text-rose-500" />
<span className="text-rose-600 dark:text-rose-400 font-semibold">{data.stats.totalAssessments}</span>
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.total_assessments').toLowerCase()}</span>
</span>
)}
</div>
</div>
</motion.div>
{/* Student Cards Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* My Competencies Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-emerald-200/60 dark:hover:shadow-emerald-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('flower')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
<Flower2 className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.my_competencies')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_competencies_desc')}</p>
{/* Mini competence flower placeholder */}
<div className="flex items-center justify-center w-full h-24 rounded-lg bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<Flower2 className="h-10 w-10 text-emerald-400/60 dark:text-emerald-600/40" />
</div>
</CardContent>
</Card>
{/* My Recent Grades Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-amber-200/40 dark:hover:ring-amber-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-amber-200/60 dark:hover:shadow-amber-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('grading')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<Calculator className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.my_grades')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-amber-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_grades_desc')}</p>
{data.recentAssessments && data.recentAssessments.length > 0 ? (
<div className="space-y-2">
{data.recentAssessments.slice(0, 3).map((a) => (
<div key={a.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-amber-50/50 dark:bg-amber-900/10 border border-amber-100/50 dark:border-amber-900/20">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{a.title}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200/50 dark:border-amber-900/30 text-amber-700 dark:text-amber-300 shrink-0">
{t('student.grade_in')} {a.title}
</Badge>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<Award className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('student.no_grades')}</p>
</div>
)}
</CardContent>
</Card>
{/* My Notebooks Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-teal-200/40 dark:hover:ring-teal-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-teal-200/60 dark:hover:shadow-teal-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('notebooks')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<BookOpen className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.my_notebooks')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-teal-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_notebooks_desc')}</p>
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gradient-to-br from-teal-50 to-emerald-50/50 dark:from-teal-900/20 dark:to-emerald-900/10 border border-teal-200/30 dark:border-teal-900/20">
<BookMarked className="h-8 w-8 text-teal-400/60 dark:text-teal-600/40 mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('student.view_all')}</p>
</div>
</CardContent>
</Card>
{/* My Attendance Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-rose-200/40 dark:hover:ring-rose-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-rose-200/60 dark:hover:shadow-rose-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('attendance')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-rose-400 to-rose-500 text-white shadow-sm">
<CalendarCheck className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.my_attendance')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-rose-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.my_attendance_desc')}</p>
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-rose-50 to-amber-50/50 dark:from-rose-900/20 dark:to-amber-900/10 border border-rose-200/30 dark:border-rose-900/20">
<div className="text-center">
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">--</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student.attendance_rate')}</p>
</div>
</div>
</CardContent>
</Card>
{/* Upcoming Assessments Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-violet-200/40 dark:hover:ring-violet-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-violet-200/60 dark:hover:shadow-violet-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('assessments')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<ClipboardCheck className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.upcoming_assessments')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">{t('student.upcoming_assessments_desc')}</p>
{data.recentAssessments && data.recentAssessments.length > 0 ? (
<div className="space-y-2">
{data.recentAssessments.slice(0, 3).map((a) => {
const daysUntil = Math.max(0, Math.ceil((new Date(a.date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
return (
<div key={a.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-violet-50/50 dark:bg-violet-900/10 border border-violet-100/50 dark:border-violet-900/20">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{a.title}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-violet-50/50 dark:bg-violet-900/10 border-violet-200/50 dark:border-violet-900/30 text-violet-700 dark:text-violet-300 shrink-0">
{daysUntil} {t('student.days_until')}
</Badge>
</div>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<ClipboardCheck className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('student.no_assessments')}</p>
</div>
)}
</CardContent>
</Card>
{/* Calendar Card */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30 transition-all duration-300 hover:shadow-lg hover:shadow-emerald-200/60 dark:hover:shadow-emerald-800/30 cursor-pointer min-h-[44px]" onClick={() => setCurrentView('calendar')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<CalendarIconNav className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('nav.calendar')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-900/20 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<div className="text-center">
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })}
</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'long' })}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
return (
<motion.div
variants={containerVariants}
@@ -685,7 +928,7 @@ export default function DashboardView() {
whileHover={{ scale: 1.01, x: 2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
onClick={() => setCurrentView(item.view)}
onClick={() => setCurrentView(item.view as ViewName)}
className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-all relative overflow-hidden"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${item.color} text-white shadow-sm shrink-0 transition-transform duration-200 group-hover:scale-110`}>
+369 -115
View File
@@ -13,6 +13,7 @@ import {
Heading1, Heading2, Heading3, CheckCircle2,
User as UserIcon, GraduationCap, FileDown,
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
Strikethrough, Type, Highlighter, GripVertical,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -27,6 +28,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
@@ -279,63 +281,73 @@ function getPageBackgroundCSS(type: string): React.CSSProperties {
}
}
// ─── Rich Text Toolbar ───────────────────────────────────────────────
// ─── WYSIWYG Rich Text Toolbar ───────────────────────────────────────
function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTextAreaElement | null> }) {
// These functions only access ref.current inside event handlers (onClick), never during render
const applyFormat = (before: string, after: string) => () => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(textarea, newText);
}
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.setSelectionRange(start + before.length + selectedText.length, start + before.length + selectedText.length);
textarea.focus();
};
const TEXT_COLORS = [
{ key: 'black', color: '#1f2937', labelKey: 'notebooks.toolbar_color_black' },
{ key: 'red', color: '#dc2626', labelKey: 'notebooks.toolbar_color_red' },
{ key: 'blue', color: '#2563eb', labelKey: 'notebooks.toolbar_color_blue' },
{ key: 'green', color: '#16a34a', labelKey: 'notebooks.toolbar_color_green' },
{ key: 'orange', color: '#ea580c', labelKey: 'notebooks.toolbar_color_orange' },
];
const applyLinePrefix = (prefix: string) => () => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + prefix + text.substring(lineStart);
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(textarea, newText);
}
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
textarea.focus();
};
const HIGHLIGHT_COLORS = [
{ key: 'yellow', color: '#fef08a', labelKey: 'notebooks.toolbar_highlight_yellow' },
{ key: 'green', color: '#bbf7d0', labelKey: 'notebooks.toolbar_highlight_green' },
{ key: 'blue', color: '#bfdbfe', labelKey: 'notebooks.toolbar_highlight_blue' },
{ key: 'pink', color: '#fbcfe8', labelKey: 'notebooks.toolbar_highlight_pink' },
{ key: 'none', color: 'transparent', labelKey: 'notebooks.toolbar_highlight_none' },
];
const applyBlockPrefix = (prefix: string) => () => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const lineEnd = text.indexOf('\n', end);
const actualEnd = lineEnd === -1 ? text.length : lineEnd;
const selectedLines = text.substring(lineStart, actualEnd);
const newLines = selectedLines.split('\n').map(line => prefix + line).join('\n');
const newText = text.substring(0, lineStart) + newLines + text.substring(actualEnd);
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(textarea, newText);
function WysiwygToolbar({ editorRef, onFormatChange }: { editorRef: React.RefObject<HTMLDivElement | null>; onFormatChange: () => void }) {
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
const updateActiveStates = useCallback(() => {
setActiveStates({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikethrough: document.queryCommandState('strikeThrough'),
justifyLeft: document.queryCommandState('justifyLeft'),
justifyCenter: document.queryCommandState('justifyCenter'),
justifyRight: document.queryCommandState('justifyRight'),
});
}, []);
const execCommand = useCallback((command: string, value?: string) => {
document.execCommand(command, false, value);
editorRef.current?.focus();
updateActiveStates();
onFormatChange();
}, [editorRef, updateActiveStates, onFormatChange]);
const applyHeading = useCallback((level: number) => {
document.execCommand('formatBlock', false, `h${level}`);
editorRef.current?.focus();
updateActiveStates();
onFormatChange();
}, [editorRef, updateActiveStates, onFormatChange]);
const applyTextColor = useCallback((color: string) => {
document.execCommand('foreColor', false, color);
editorRef.current?.focus();
updateActiveStates();
onFormatChange();
}, [editorRef, updateActiveStates, onFormatChange]);
const applyHighlight = useCallback((color: string) => {
if (color === 'transparent') {
document.execCommand('removeFormat', false);
} else {
document.execCommand('hiliteColor', false, color);
}
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
textarea.focus();
};
editorRef.current?.focus();
updateActiveStates();
onFormatChange();
}, [editorRef, updateActiveStates, onFormatChange]);
const fmtBtnClass = (active: boolean) =>
`min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0 ${active ? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-gray-100' : ''}`;
return (
<TooltipProvider delayDuration={300}>
@@ -343,7 +355,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
{/* Format group */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyFormat('**', '**')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('bold')} className={fmtBtnClass(activeStates.bold)}>
<Bold className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -351,7 +363,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyFormat('*', '*')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('italic')} className={fmtBtnClass(activeStates.italic)}>
<ItalicIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -359,19 +371,27 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyFormat('_', '_')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('underline')} className={fmtBtnClass(activeStates.underline)}>
<UnderlineIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_underline')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={() => execCommand('strikeThrough')} className={fmtBtnClass(activeStates.strikethrough)}>
<Strikethrough className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_strikethrough')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5 mx-1" />
{/* Heading group */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyLinePrefix('# ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => applyHeading(1)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Heading1 className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -379,7 +399,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyLinePrefix('## ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => applyHeading(2)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Heading2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -387,7 +407,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyLinePrefix('### ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => applyHeading(3)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Heading3 className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -399,7 +419,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
{/* List group */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyBlockPrefix('- ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('insertUnorderedList')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<List className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -407,7 +427,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyBlockPrefix('1. ')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('insertOrderedList')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<ListOrdered className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -419,7 +439,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
{/* Alignment group */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyLinePrefix('')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyLeft')} className={fmtBtnClass(activeStates.justifyLeft)}>
<AlignLeftIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -427,7 +447,7 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyLinePrefix('::center::\n')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyCenter')} className={fmtBtnClass(activeStates.justifyCenter)}>
<AlignCenterIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
@@ -435,12 +455,74 @@ function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject<HTMLTex
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={applyLinePrefix('::right::\n')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyRight')} className={fmtBtnClass(activeStates.justifyRight)}>
<AlignRightIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_align_right')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5 mx-1" />
{/* Text color */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Type className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_text_color')}</TooltipContent>
</Tooltip>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex items-center gap-1.5">
{TEXT_COLORS.map((tc) => (
<button
key={tc.key}
onClick={() => applyTextColor(tc.color)}
className="w-7 h-7 rounded-full border-2 border-gray-200 dark:border-gray-600 hover:scale-110 transition-transform"
style={{ backgroundColor: tc.color }}
title={t(tc.labelKey)}
/>
))}
</div>
</PopoverContent>
</Popover>
{/* Highlight color */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
<Highlighter className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_highlight')}</TooltipContent>
</Tooltip>
<PopoverContent className="w-auto p-2" align="start">
<div className="flex items-center gap-1.5">
{HIGHLIGHT_COLORS.map((hc) => (
<button
key={hc.key}
onClick={() => applyHighlight(hc.color)}
className={`w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform ${
hc.key === 'none'
? 'border-gray-300 dark:border-gray-500 bg-white dark:bg-gray-800 flex items-center justify-center'
: 'border-gray-200 dark:border-gray-600'
}`}
style={hc.key !== 'none' ? { backgroundColor: hc.color } : {}}
title={t(hc.labelKey)}
>
{hc.key === 'none' && <X className="w-3.5 h-3.5 text-gray-400" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</TooltipProvider>
);
@@ -897,6 +979,7 @@ function NotebookDetailView({
onDeletePage,
onToggleBookmark,
onTogglePublic,
onReorderPages,
}: {
notebook: Notebook;
subjectName: string | null;
@@ -906,6 +989,7 @@ function NotebookDetailView({
onDeletePage: (pageId: string) => void;
onToggleBookmark: (pageId: string) => void;
onTogglePublic: () => void;
onReorderPages: (pageOrders: Array<{ id: string; pageNumber: number }>) => Promise<void>;
}) {
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [pageContent, setPageContent] = useState('');
@@ -914,39 +998,71 @@ function NotebookDetailView({
const [saving, setSaving] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Drag-and-drop state
const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedContentRef = useRef<string>('');
const lastSavedTitleRef = useRef<string>('');
const isInternalChange = useRef(false);
const pages = notebook.pages ?? [];
const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null;
// Set content into editor ref without triggering auto-save
const setEditorContent = useCallback((html: string) => {
isInternalChange.current = true;
if (editorRef.current) {
editorRef.current.innerHTML = html;
}
setPageContent(html);
// Reset flag after React processes the state update
setTimeout(() => { isInternalChange.current = false; }, 50);
}, []);
useEffect(() => {
if (currentPage) {
setCurrentPageId(currentPage.id);
setPageContent(currentPage.textContent ?? '');
setEditorContent(currentPage.textContent ?? '');
setPageTitle(currentPage.title ?? '');
lastSavedContentRef.current = currentPage.textContent ?? '';
lastSavedTitleRef.current = currentPage.title ?? '';
} else if (pages.length > 0) {
setCurrentPageId(pages[0].id);
setPageContent(pages[0].textContent ?? '');
setEditorContent(pages[0].textContent ?? '');
setPageTitle(pages[0].title ?? '');
lastSavedContentRef.current = pages[0].textContent ?? '';
lastSavedTitleRef.current = pages[0].title ?? '';
}
}, [notebook.id]);
}, [notebook.id, setEditorContent]);
useEffect(() => {
if (currentPage) {
setPageContent(currentPage.textContent ?? '');
setEditorContent(currentPage.textContent ?? '');
setPageTitle(currentPage.title ?? '');
lastSavedContentRef.current = currentPage.textContent ?? '';
lastSavedTitleRef.current = currentPage.title ?? '';
setAutoSaveStatus('idle');
}
}, [currentPageId]);
}, [currentPageId, setEditorContent]);
// Handle content change from contentEditable div
const handleEditorInput = useCallback(() => {
if (editorRef.current) {
const html = editorRef.current.innerHTML;
setPageContent(html);
}
}, []);
// Handle format change from toolbar (for active state refresh)
const handleFormatChange = useCallback(() => {
if (editorRef.current) {
const html = editorRef.current.innerHTML;
setPageContent(html);
}
}, []);
// Auto-save with 3 second debounce
const triggerAutoSave = useCallback(() => {
@@ -968,7 +1084,6 @@ function NotebookDetailView({
lastSavedContentRef.current = pageContent;
lastSavedTitleRef.current = pageTitle;
setAutoSaveStatus('saved');
// Reset status after 2 seconds
setTimeout(() => setAutoSaveStatus('idle'), 2000);
} catch {
setAutoSaveStatus('idle');
@@ -978,6 +1093,7 @@ function NotebookDetailView({
// Trigger auto-save on content or title change
useEffect(() => {
if (isInternalChange.current) return;
triggerAutoSave();
return () => {
if (autoSaveTimerRef.current) {
@@ -988,17 +1104,22 @@ function NotebookDetailView({
const handleSavePage = async () => {
if (!currentPage) return;
// Sync content from editor
if (editorRef.current) {
setPageContent(editorRef.current.innerHTML);
}
// Cancel pending auto-save
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
setSaving(true);
try {
const contentToSave = editorRef.current?.innerHTML ?? pageContent;
await onUpdatePage(currentPage.id, {
textContent: pageContent,
textContent: contentToSave,
title: pageTitle.trim() || null,
});
lastSavedContentRef.current = pageContent;
lastSavedContentRef.current = contentToSave;
lastSavedTitleRef.current = pageTitle;
toast.success(t('notebooks.page_saved'));
setAutoSaveStatus('idle');
@@ -1009,6 +1130,94 @@ function NotebookDetailView({
}
};
// Drag-and-drop handlers for page reorder
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
setDraggedPageId(pageId);
e.dataTransfer.effectAllowed = 'move';
// Set a transparent drag image
const ghost = document.createElement('div');
ghost.style.opacity = '0';
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, pageId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedPageId && pageId !== draggedPageId) {
setDragOverPageId(pageId);
}
}, [draggedPageId]);
const handleDragLeave = useCallback(() => {
setDragOverPageId(null);
}, []);
const handleDrop = useCallback(async (targetPageId: string) => {
if (!draggedPageId || draggedPageId === targetPageId) {
setDraggedPageId(null);
setDragOverPageId(null);
return;
}
const draggedIdx = pages.findIndex(p => p.id === draggedPageId);
const targetIdx = pages.findIndex(p => p.id === targetPageId);
if (draggedIdx === -1 || targetIdx === -1) {
setDraggedPageId(null);
setDragOverPageId(null);
return;
}
// Reorder pages locally
const newPages = [...pages];
const [removed] = newPages.splice(draggedIdx, 1);
newPages.splice(targetIdx, 0, removed);
// Build new pageOrders array
const pageOrders = newPages.map((p, i) => ({ id: p.id, pageNumber: i + 1 }));
setDraggedPageId(null);
setDragOverPageId(null);
try {
await onReorderPages(pageOrders);
} catch {
toast.error(t('notebooks.reorder_error'));
}
}, [draggedPageId, pages, onReorderPages]);
const handleDragEnd = useCallback(() => {
setDraggedPageId(null);
setDragOverPageId(null);
}, []);
// Touch-based drag handlers for tablets
const touchDragRef = useRef<{ pageId: string; startY: number; clone: HTMLElement | null } | null>(null);
const handleTouchStart = useCallback((e: React.TouchEvent, pageId: string) => {
const touch = e.touches[0];
touchDragRef.current = { pageId, startY: touch.clientY, clone: null };
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!touchDragRef.current) return;
const touch = e.touches[0];
const deltaY = Math.abs(touch.clientY - touchDragRef.current.startY);
if (deltaY > 10 && !touchDragRef.current.clone) {
setDraggedPageId(touchDragRef.current.pageId);
}
}, []);
const handleTouchEnd = useCallback(() => {
if (dragOverPageId && touchDragRef.current && dragOverPageId !== touchDragRef.current.pageId) {
handleDrop(dragOverPageId);
}
touchDragRef.current = null;
setDraggedPageId(null);
setDragOverPageId(null);
}, [dragOverPageId, handleDrop]);
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
return (
@@ -1114,42 +1323,70 @@ function NotebookDetailView({
<ScrollArea className="flex-1 max-h-[calc(100vh-280px)]">
<div className="p-2 space-y-2">
{pages.map((page, idx) => (
<motion.div
key={page.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.03 }}
className={`rounded-lg transition-all ${
currentPageId === page.id
? 'ring-2 ring-emerald-500'
: ''
}`}
>
{/* Page thumbnail */}
<PageThumbnail page={page} notebookType={notebook.notebookType} />
{/* Page info below thumbnail */}
<button
onClick={() => { setCurrentPageId(page.id); setDrawingMode(false); }}
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
{pages.map((page, idx) => {
const isDragged = draggedPageId === page.id;
const isDragOver = dragOverPageId === page.id;
return (
<motion.div
key={page.id}
initial={{ opacity: 0, x: -10 }}
animate={{
opacity: isDragged ? 0.5 : 1,
x: 0,
scale: isDragged ? 0.95 : 1,
}}
transition={{ delay: idx * 0.03 }}
layout
className={`rounded-lg transition-all ${
currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
? 'ring-2 ring-emerald-500'
: ''
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
isDragged ? 'shadow-md' : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(page.id)}
onDragEnd={handleDragEnd}
onTouchStart={(e) => handleTouchStart(e, page.id)}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
{page.pageNumber}
</span>
<span className="truncate flex-1">
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
</span>
{page.isBookmark && (
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
)}
</button>
</motion.div>
))}
{/* Drag handle */}
<div className="flex items-center">
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
<div className="flex-1">
{/* Page thumbnail */}
<PageThumbnail page={page} notebookType={notebook.notebookType} />
</div>
</div>
{/* Page info below thumbnail */}
<button
onClick={() => { setCurrentPageId(page.id); setDrawingMode(false); }}
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
{page.pageNumber}
</span>
<span className="truncate flex-1">
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
</span>
{page.isBookmark && (
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
)}
</button>
</motion.div>
);
})}
</div>
</ScrollArea>
@@ -1220,10 +1457,10 @@ function NotebookDetailView({
</motion.div>
</div>
) : (
/* Text editing area with rich text toolbar and page background */
/* Text editing area with WYSIWYG toolbar and page background */
<div className="flex-1 flex flex-col overflow-hidden">
{/* Rich text toolbar */}
<RichTextToolbar textareaRef={textareaRef} />
{/* WYSIWYG toolbar */}
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
{/* Page content area */}
<div className="flex-1 overflow-hidden p-4">
@@ -1236,12 +1473,13 @@ function NotebookDetailView({
className="min-h-full p-6"
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
>
<textarea
ref={textareaRef}
value={pageContent}
onChange={(e) => setPageContent(e.target.value)}
placeholder={t('notebooks.page_content') + '...'}
className="w-full min-h-[500px] bg-transparent border-0 outline-none resize-none text-base leading-8 text-gray-800 dark:text-gray-200 placeholder:text-gray-300 dark:placeholder:text-gray-600 focus:ring-0"
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={handleEditorInput}
data-placeholder={t('notebooks.page_content') + '...'}
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
/>
</div>
@@ -1507,6 +1745,21 @@ export default function NotebooksView() {
setNotebooks(prev => prev.map(n => n.id === updated.id ? updated : n));
}, [selectedNotebook]);
const handleReorderPages = useCallback(async (pageOrders: Array<{ id: string; pageNumber: number }>) => {
if (!selectedNotebook) return;
const updatedPages = await apiPut<NotebookPage[]>(`/api/notebooks/${selectedNotebook.id}/pages/reorder`, {
pageOrders,
});
setSelectedNotebook(prev => {
if (!prev) return prev;
return {
...prev,
pages: updatedPages,
};
});
toast.success(t('notebooks.reorder_success'));
}, [selectedNotebook]);
const handleShare = useCallback(async (notebook: Notebook) => {
setShareConfirmNotebook(notebook);
}, []);
@@ -1646,6 +1899,7 @@ export default function NotebooksView() {
onDeletePage={handleDeletePage}
onToggleBookmark={handleToggleBookmark}
onTogglePublic={handleTogglePublic}
onReorderPages={handleReorderPages}
/>
</div>
);
+2 -1
View File
@@ -121,6 +121,7 @@ const relativeDate = (dateStr: string) => {
export default function ProgressEntriesView() {
const currentUser = useAppStore((s) => s.currentUser);
const currentClassId = useAppStore((s) => s.currentClassId);
const locale = useAppStore((s) => s.locale);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [selectedClass, setSelectedClass] = useState<ClassGroup | null>(null);
@@ -915,7 +916,7 @@ export default function ProgressEntriesView() {
style={{
backgroundColor: `${catColor}20`,
color: catColor,
ringColor: `${catColor}30`,
outlineColor: `${catColor}30`,
}}
>
{entry.competency.category.name[0]}
+27 -25
View File
@@ -1479,33 +1479,35 @@ export default function SettingsView() {
</CardContent>
</Card>
{/* GDPR Data Erasure */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-rose-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-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-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<AlertTriangle className="h-4 w-4" />
</div>
{t('settings.data_erasure')}
</CardTitle>
<CardDescription>{t('settings.data_erasure_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 rounded-xl bg-rose-50/50 dark:bg-rose-900/10 border border-rose-200/30 dark:border-rose-900/20">
<p className="text-sm text-rose-700 dark:text-rose-300 font-medium">
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" /> {t('settings.data_erasure_desc')}
{/* GDPR Data Erasure — Danger Zone */}
<div className="danger-zone">
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<AlertTriangle className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-rose-700 dark:text-rose-300">
{t('settings.data_erasure')}
</h3>
<p className="text-xs text-rose-600/70 dark:text-rose-400/60">
{t('settings.data_erasure_desc')}
</p>
</div>
<Button
onClick={() => setShowErasureDialog(true)}
variant="outline"
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl px-6"
>
<Trash2 className="h-4 w-4 mr-2" />
{t('settings.data_erasure_request')}
</Button>
</CardContent>
</Card>
</div>
<div className="p-3 rounded-lg bg-rose-50/50 dark:bg-rose-900/10 border border-rose-200/30 dark:border-rose-900/20 mb-3">
<p className="text-xs text-rose-700 dark:text-rose-300 font-medium">
<AlertTriangle className="w-3 h-3 inline mr-1" /> {t('settings.data_erasure_desc')}
</p>
</div>
<Button
onClick={() => setShowErasureDialog(true)}
variant="outline"
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl px-6 min-h-[44px]"
>
<Trash2 className="h-4 w-4 mr-2" />
{t('settings.data_erasure_request')}
</Button>
</div>
{/* Erasure Dialog */}
<AlertDialog open={showErasureDialog} onOpenChange={setShowErasureDialog}>