Round 19: Professional Notebooks, Digital Rewards, GDPR, Role-Based Dashboard, Deep Styling Polish
- Fixed attendance API 403: added TEACHER school membership check - Fixed competitions API 403: cross-school access, null schoolId, public fallback - Fixed GDPR export API 500: 16 Prisma field name mismatches - Enhanced notebooks: page templates (Cornell, Mind Map, Venn, T-Chart, Weekly Planner), sticky notes, search, page duplication, sections/chapters, highlighter tool - Added scratch pad with DrawingCanvas in assessments for hand-written solutions - Role-based dashboard: TEACHER (competence radar, upcoming lessons), STUDENT (grades trend, competitions), PARENT (children grades, calendar), SCHOOL_ADMIN (system health, audit log, user management) - Digital reward system: 16 demo rewards, points earning/redemption, categories (streaming, shopping, experiences, merchandise, privileges) - GDPR compliance: consent management, data retention, cookie consent - Deep styling polish: glassmorphism, gradient headers, animated sidebar, card hover effects, progress bar animations, timeline dots, FAB button - 160+ new i18n keys (DE/EN)
This commit is contained in:
1 parent
86e4b504e4
commit
6d9b42ac37
43 files changed
+4569
-140
No files matched your search
@@ -827,7 +827,7 @@ export default function AppLayout() {
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
className="border-r border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-b from-emerald-50/80 via-white to-white dark:from-gray-900 dark:via-emerald-950/20 dark:to-gray-950"
|
||||
className="border-r border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-b from-emerald-50/80 via-white to-emerald-50/30 dark:from-gray-900 dark:via-emerald-950/20 dark:to-gray-950"
|
||||
>
|
||||
<SidebarHeader className="p-4">
|
||||
<motion.div
|
||||
@@ -874,16 +874,20 @@ export default function AppLayout() {
|
||||
className={`group relative transition-all duration-200 min-h-[44px] ${
|
||||
currentView === item.key
|
||||
? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm shadow-emerald-100/40 dark:shadow-emerald-900/20'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10 hover:translate-x-0.5'
|
||||
}`}
|
||||
>
|
||||
{currentView === item.key && (
|
||||
<span className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500" />
|
||||
<motion.span
|
||||
layoutId="sidebar-active-indicator"
|
||||
className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500"
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<item.icon className={`h-5 w-5 transition-colors duration-200 ${
|
||||
<item.icon className={`h-5 w-5 transition-all duration-200 ${
|
||||
currentView === item.key
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400'
|
||||
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400 group-hover:scale-110'
|
||||
}`} />
|
||||
<span className="text-sm">{t(item.labelKey)}</span>
|
||||
</SidebarMenuButton>
|
||||
@@ -970,7 +974,7 @@ export default function AppLayout() {
|
||||
<SidebarInset>
|
||||
{/* Announcement Banner */}
|
||||
<AnnouncementBanner schoolId={currentUser?.schoolId ?? null} />
|
||||
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
|
||||
<header className="header-gradient flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
|
||||
<SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
|
||||
<Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" />
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
@@ -985,7 +989,7 @@ export default function AppLayout() {
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator><ChevronRight className="h-3 w-3 text-emerald-400/60" /></BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
<BreadcrumbPage className="breadcrumb-active-view truncate">
|
||||
{breadcrumbLabel}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
@@ -1066,7 +1070,7 @@ export default function AppLayout() {
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
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"
|
||||
className="animate-badge-pulse 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>
|
||||
@@ -1207,7 +1211,7 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-4 md:p-6 bg-white dark:bg-gray-950">
|
||||
<div className="flex-1 p-4 md:p-6 bg-gradient-to-br from-white via-white to-emerald-50/20 dark:from-gray-950 dark:via-gray-950 dark:to-emerald-950/10">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentView}
|
||||
@@ -1221,7 +1225,7 @@ export default function AppLayout() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 py-3 px-4">
|
||||
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white via-emerald-50/20 to-white dark:from-gray-950 dark:via-emerald-950/10 dark:to-gray-950 py-3 px-4">
|
||||
<div className="flex items-center justify-between text-xs text-emerald-600/60 dark:text-emerald-400/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<Leaf className="h-3 w-3 text-emerald-500" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer, ReferenceLine, Tooltip as RechartsTooltip,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home,
|
||||
Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles,
|
||||
PenTool, GripVertical, ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -18,6 +19,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
|
||||
import DrawingCanvas from '@/components/drawing-canvas';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import StudentAvatar from '@/components/student-avatar';
|
||||
@@ -107,9 +110,9 @@ const relativeDate = (dateStr: string) => {
|
||||
};
|
||||
|
||||
const resultCountBadge = (count: number) => {
|
||||
if (count === 0) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||
if (count < 5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
|
||||
if (count === 0) return 'grade-badge-below';
|
||||
if (count < 5) return 'grade-badge-average';
|
||||
return 'grade-badge-excellent';
|
||||
};
|
||||
|
||||
// Emerald gradient colors for score distribution bars
|
||||
@@ -255,6 +258,105 @@ export default function AssessmentsView() {
|
||||
gradingAssessment?.maxScore ?? null
|
||||
);
|
||||
|
||||
// Scratch pad state
|
||||
const [scratchPadOpen, setScratchPadOpen] = useState(false);
|
||||
const [scratchPadWidth, setScratchPadWidth] = useState(480);
|
||||
const [scratchPadDrawingData, setScratchPadDrawingData] = useState<string | null>(null);
|
||||
const [scratchPadDrawingId, setScratchPadDrawingId] = useState<string | null>(null);
|
||||
const [scratchPadSaving, setScratchPadSaving] = useState(false);
|
||||
const scratchPadResizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
// Load existing scratch pad drawing for the current assessment
|
||||
const loadScratchPadDrawing = useCallback(async (assessmentId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/drawings?subjectId=${assessmentId}`);
|
||||
if (res.ok) {
|
||||
const drawings = await res.json();
|
||||
// Find a scratch pad drawing for this assessment
|
||||
const scratchPad = drawings.find((d: { title: string; description: string }) =>
|
||||
d.title?.startsWith('Scratch Pad:') || d.description?.startsWith('assessment-scratch-pad:')
|
||||
);
|
||||
if (scratchPad) {
|
||||
setScratchPadDrawingId(scratchPad.id);
|
||||
setScratchPadDrawingData(scratchPad.drawingData);
|
||||
} else {
|
||||
setScratchPadDrawingId(null);
|
||||
setScratchPadDrawingData(null);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save scratch pad drawing
|
||||
const handleSaveScratchPad = useCallback(async (drawingData: string, imageData: string) => {
|
||||
if (!gradingAssessment) return;
|
||||
setScratchPadSaving(true);
|
||||
try {
|
||||
const title = `Scratch Pad: ${gradingAssessment.title}`;
|
||||
const description = `assessment-scratch-pad:${gradingAssessment.id}`;
|
||||
if (scratchPadDrawingId) {
|
||||
// Update existing drawing
|
||||
await fetch(`/api/drawings/${scratchPadDrawingId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ drawingData, imageData, title }),
|
||||
});
|
||||
} else {
|
||||
// Create new drawing
|
||||
const res = await fetch('/api/drawings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
drawingData,
|
||||
imageData,
|
||||
subjectId: gradingAssessment.subjectId ?? undefined,
|
||||
classGroupId: gradingAssessment.classGroupId ?? undefined,
|
||||
schoolId: currentUser?.schoolId ?? undefined,
|
||||
isPublic: false,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
setScratchPadDrawingId(created.id);
|
||||
}
|
||||
}
|
||||
toast.success(t('assessments.scratch_pad_saved'));
|
||||
} catch {
|
||||
toast.error(t('error.generic'));
|
||||
} finally {
|
||||
setScratchPadSaving(false);
|
||||
}
|
||||
}, [gradingAssessment, scratchPadDrawingId, currentUser?.schoolId]);
|
||||
|
||||
// Handle mouse move for resizing
|
||||
const handleResizeMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!scratchPadResizeRef.current) return;
|
||||
const delta = scratchPadResizeRef.current.startX - e.clientX;
|
||||
const newWidth = Math.min(Math.max(scratchPadResizeRef.current.startWidth + delta, 320), window.innerWidth * 0.7);
|
||||
setScratchPadWidth(newWidth);
|
||||
}, []);
|
||||
|
||||
const handleResizeMouseUp = useCallback(() => {
|
||||
scratchPadResizeRef.current = null;
|
||||
document.removeEventListener('mousemove', handleResizeMouseMove);
|
||||
document.removeEventListener('mouseup', handleResizeMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}, [handleResizeMouseMove]);
|
||||
|
||||
const startResize = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
scratchPadResizeRef.current = { startX: e.clientX, startWidth: scratchPadWidth };
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', handleResizeMouseMove);
|
||||
document.addEventListener('mouseup', handleResizeMouseUp);
|
||||
}, [scratchPadWidth, handleResizeMouseMove, handleResizeMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
@@ -344,6 +446,9 @@ export default function AssessmentsView() {
|
||||
const openGrading = async (assessment: Assessment) => {
|
||||
setGradingAssessment(assessment);
|
||||
setGradingOpen(true);
|
||||
setScratchPadOpen(false);
|
||||
setScratchPadDrawingId(null);
|
||||
setScratchPadDrawingData(null);
|
||||
try {
|
||||
const results = await fetchAssessmentResults(assessment.id);
|
||||
setGradingResults(results);
|
||||
@@ -357,6 +462,8 @@ export default function AssessmentsView() {
|
||||
};
|
||||
}
|
||||
setGradingScores(scoresMap);
|
||||
// Load existing scratch pad drawing
|
||||
loadScratchPadDrawing(assessment.id);
|
||||
} catch {
|
||||
setGradingResults([]);
|
||||
}
|
||||
@@ -486,7 +593,7 @@ export default function AssessmentsView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
@@ -580,10 +687,26 @@ export default function AssessmentsView() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={() => openGrading(a)}>
|
||||
<PenLine className="h-3 w-3 mr-1" />
|
||||
{t('assessments.enter_scores')}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="min-h-[44px] min-w-[44px] rounded-xl text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
|
||||
onClick={() => {
|
||||
setGradingAssessment(a);
|
||||
setScratchPadOpen(true);
|
||||
loadScratchPadDrawing(a.id);
|
||||
}}
|
||||
title={t('assessments.scratch_pad_open')}
|
||||
>
|
||||
<PenTool className="h-4 w-4" />
|
||||
<span className="sr-only sm:not-sr-only sm:ml-1.5 text-xs">{t('assessments.scratch_pad')}</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 min-h-[44px] min-w-[44px]" onClick={() => openGrading(a)}>
|
||||
<PenLine className="h-3 w-3 mr-1" />
|
||||
{t('assessments.enter_scores')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -685,6 +808,15 @@ export default function AssessmentsView() {
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
{t('label.max_score')}: <strong>{gradingAssessment.maxScore ?? '—'}</strong>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
|
||||
onClick={() => setScratchPadOpen(true)}
|
||||
>
|
||||
<PenTool className="h-4 w-4 mr-1.5" />
|
||||
{t('assessments.scratch_pad')}
|
||||
</Button>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
@@ -792,6 +924,73 @@ export default function AssessmentsView() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Scratch Pad Sheet */}
|
||||
<Sheet open={scratchPadOpen} onOpenChange={setScratchPadOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="p-0 gap-0 w-auto sm:max-w-none flex flex-col"
|
||||
style={{ width: scratchPadWidth }}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 w-2 cursor-col-resize hover:bg-emerald-200/40 dark:hover:bg-emerald-700/40 active:bg-emerald-300/60 dark:active:bg-emerald-600/60 transition-colors z-10 flex items-center justify-center"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('assessments.scratch_pad_resize')}
|
||||
tabIndex={0}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<SheetHeader className="px-6 pt-4 pb-2 border-b border-emerald-200/30 dark:border-emerald-900/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<PenTool className="h-4 w-4" />
|
||||
</div>
|
||||
<SheetTitle className="text-base font-semibold">
|
||||
{t('assessments.scratch_pad_title', { title: gradingAssessment?.title ?? '' })}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300"
|
||||
onClick={() => {
|
||||
if (scratchPadWidth <= 480) setScratchPadWidth(640);
|
||||
else if (scratchPadWidth <= 640) setScratchPadWidth(860);
|
||||
else setScratchPadWidth(480);
|
||||
}}
|
||||
title={t('assessments.scratch_pad_resize')}
|
||||
>
|
||||
{scratchPadWidth <= 480 ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SheetDescription className="text-xs text-muted-foreground mt-1">
|
||||
{t('assessments.scratch_pad_description')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DrawingCanvas
|
||||
backgroundType="lined"
|
||||
initialDrawingData={scratchPadDrawingData ?? undefined}
|
||||
onSave={handleSaveScratchPad}
|
||||
title={`Scratch Pad: ${gradingAssessment?.title ?? ''}`}
|
||||
/>
|
||||
</div>
|
||||
{scratchPadSaving && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 text-sm shadow-lg">
|
||||
<div className="h-4 w-4 border-2 border-emerald-600 dark:border-emerald-400 border-t-transparent rounded-full animate-spin" />
|
||||
{t('assessments.scratch_pad_saving')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -863,7 +863,7 @@ export default function ClassesView() {
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => handleSelectClass(cls)}
|
||||
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-hover-lift ${
|
||||
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-shadow-transition ${
|
||||
selectedClass?.id === cls.id
|
||||
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
|
||||
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
|
||||
@@ -900,7 +900,7 @@ export default function ClassesView() {
|
||||
{/* Small progress indicator */}
|
||||
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
|
||||
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
|
||||
style={{ width: `${progressPercent(cls)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1459,7 +1459,7 @@ export default function ClassesView() {
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: idx * 0.03, duration: 0.3 }}
|
||||
className="flex flex-col items-center gap-1 cursor-pointer"
|
||||
className="animate-student-appear flex flex-col items-center gap-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
useAppStore.getState().navigateToStudentDetail(s.id, 'classes');
|
||||
}}
|
||||
|
||||
@@ -561,7 +561,7 @@ export default function CompetenceFlowerView() {
|
||||
</Collapsible>
|
||||
{/* Radar chart */}
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
|
||||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
@@ -786,7 +786,7 @@ export default function CompetenceFlowerView() {
|
||||
</motion.div>
|
||||
|
||||
{/* Breakdown table */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
|
||||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg text-white" style={{ backgroundColor: lightenColor(primaryColor, 0.1) }}>
|
||||
@@ -861,7 +861,7 @@ export default function CompetenceFlowerView() {
|
||||
</Card>
|
||||
|
||||
{/* Progress over time */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
||||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
|
||||
@@ -896,7 +896,7 @@ export default function CompetenceFlowerView() {
|
||||
{/* Comparison + strengths/weaknesses */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Comparison card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
@@ -963,7 +963,7 @@ export default function CompetenceFlowerView() {
|
||||
</Card>
|
||||
|
||||
{/* Strengths & weaknesses */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
|
||||
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold 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">
|
||||
|
||||
@@ -48,6 +48,22 @@ import {
|
||||
Theater,
|
||||
ShoppingBag,
|
||||
Tag,
|
||||
Wallet,
|
||||
Package,
|
||||
Coins,
|
||||
CreditCard,
|
||||
Headphones,
|
||||
Tv,
|
||||
MonitorPlay,
|
||||
Store,
|
||||
Palette,
|
||||
Coffee,
|
||||
Armchair,
|
||||
PencilRuler,
|
||||
Sticker,
|
||||
Notebook,
|
||||
History,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -84,6 +100,7 @@ import { useAppStore, type CurrentUser } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
fetchCompetitions,
|
||||
fetchPublicCompetitions,
|
||||
fetchCompetition,
|
||||
createCompetition,
|
||||
updateCompetition,
|
||||
@@ -101,6 +118,63 @@ import {
|
||||
} from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/* ── Types for Digital Reward Catalog ──────────────────────────────── */
|
||||
|
||||
interface CatalogReward {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
category: string;
|
||||
pointsCost: number;
|
||||
image?: string | null;
|
||||
stock?: number | null;
|
||||
isActive: boolean;
|
||||
isDemo: boolean;
|
||||
createdAt: string;
|
||||
_count?: { redemptions: number };
|
||||
}
|
||||
|
||||
interface PointsHistoryEntry {
|
||||
id: string;
|
||||
points: number;
|
||||
source: string;
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface RedemptionEntry {
|
||||
id: string;
|
||||
rewardId: string;
|
||||
pointsSpent: number;
|
||||
status: string;
|
||||
note?: string | null;
|
||||
createdAt: string;
|
||||
reward?: { title: string; category: string };
|
||||
}
|
||||
|
||||
/* ── Constants ─────────────────────────────────────────────────────── */
|
||||
|
||||
const REWARD_CATEGORIES = ['streaming', 'shopping', 'experience', 'merchandise', 'privilege'] as const;
|
||||
|
||||
const DEMO_REWARDS: CatalogReward[] = [
|
||||
{ id: 'demo-netflix', title: 'Netflix-Gutschein', description: '15 EUR Netflix-Gutschein', category: 'streaming', pointsCost: 500, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-spotify', title: 'Spotify Premium', description: '3 Monate Spotify Premium', category: 'streaming', pointsCost: 350, stock: 15, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-disney', title: 'Disney+-Gutschein', description: '1 Monat Disney+', category: 'streaming', pointsCost: 300, stock: 8, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-amazon', title: 'Amazon-Gutschein', description: '10 EUR Amazon-Gutschein', category: 'shopping', pointsCost: 400, stock: 20, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-thalia', title: 'Thalia-Gutschein', description: '10 EUR Thalia-Gutschein', category: 'shopping', pointsCost: 400, stock: 12, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-mediamarkt', title: 'MediaMarkt-Gutschein', description: '15 EUR MediaMarkt-Gutschein', category: 'shopping', pointsCost: 550, stock: 5, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-cinema', title: 'Kino-Ticket', description: 'Ein Kinoticket nach Wahl', category: 'experience', pointsCost: 250, stock: 25, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-theater', title: 'Theater-Ticket', description: 'Ein Theaterbesuch', category: 'experience', pointsCost: 300, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-concert', title: 'Konzert-Ticket', description: 'Konzert-Erlebnis', category: 'experience', pointsCost: 600, stock: 5, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-museum', title: 'Museumseintritt', description: 'Freier Eintritt ins Museum', category: 'experience', pointsCost: 150, stock: 30, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-tshirt', title: 'Schul-T-Shirt', description: 'Offizielles Schul-T-Shirt', category: 'merchandise', pointsCost: 200, stock: 50, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-stickers', title: 'Aufkleber-Set', description: 'Schul-Sticker-Set', category: 'merchandise', pointsCost: 50, stock: 100, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-notebook', title: 'Schulheft', description: 'Schulheft mit Schullogo', category: 'merchandise', pointsCost: 75, stock: 80, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-homework', title: 'Hausaufgaben-Frei', description: 'Eine Hausaufgabe aussetzen', category: 'privilege', pointsCost: 150, stock: 20, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-break', title: 'Extra Pause', description: '5 Minuten extra Pause', category: 'privilege', pointsCost: 100, stock: 30, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
{ id: 'demo-seat', title: 'Sitzplatz wählen', description: 'Sitzplatz für eine Woche frei wählen', category: 'privilege', pointsCost: 200, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
const COMPETITION_TYPES = ['class', 'inter_class', 'inter_school'] as const;
|
||||
@@ -234,6 +308,92 @@ function getScoringLabel(type: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryLabelReward(category: string): string {
|
||||
switch (category) {
|
||||
case 'streaming': return t('rewards.category.streaming');
|
||||
case 'shopping': return t('rewards.category.shopping');
|
||||
case 'experience': return t('rewards.category.experience');
|
||||
case 'merchandise': return t('rewards.category.merchandise');
|
||||
case 'privilege': return t('rewards.category.privilege');
|
||||
default: return category;
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: string, className?: string) {
|
||||
switch (category) {
|
||||
case 'streaming': return <Tv className={className} />;
|
||||
case 'shopping': return <ShoppingBag className={className} />;
|
||||
case 'experience': return <Ticket className={className} />;
|
||||
case 'merchandise': return <Package className={className} />;
|
||||
case 'privilege': return <Crown className={className} />;
|
||||
default: return <Gift className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryColor(category: string): string {
|
||||
switch (category) {
|
||||
case 'streaming': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
|
||||
case 'shopping': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
|
||||
case 'experience': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300';
|
||||
case 'merchandise': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
|
||||
case 'privilege': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryBorderColor(category: string): string {
|
||||
switch (category) {
|
||||
case 'streaming': return 'border-l-rose-400';
|
||||
case 'shopping': return 'border-l-amber-400';
|
||||
case 'experience': return 'border-l-violet-400';
|
||||
case 'merchandise': return 'border-l-teal-400';
|
||||
case 'privilege': return 'border-l-emerald-400';
|
||||
default: return 'border-l-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryBgGradient(category: string): string {
|
||||
switch (category) {
|
||||
case 'streaming': return 'from-rose-50/50 dark:from-rose-900/10';
|
||||
case 'shopping': return 'from-amber-50/50 dark:from-amber-900/10';
|
||||
case 'experience': return 'from-violet-50/50 dark:from-violet-900/10';
|
||||
case 'merchandise': return 'from-teal-50/50 dark:from-teal-900/10';
|
||||
case 'privilege': return 'from-emerald-50/50 dark:from-emerald-900/10';
|
||||
default: return 'from-gray-50/50 dark:from-gray-800/50';
|
||||
}
|
||||
}
|
||||
|
||||
function getRedemptionStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending': return t('rewards.redemption.pending');
|
||||
case 'approved': return t('rewards.redemption.approved');
|
||||
case 'rejected': return t('rewards.redemption.rejected');
|
||||
case 'fulfilled': return t('rewards.redemption.fulfilled');
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
function getRedemptionStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
|
||||
case 'approved': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
|
||||
case 'rejected': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
|
||||
case 'fulfilled': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
function getPointsSourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'competition': return t('rewards.points_source.competition');
|
||||
case 'grade': return t('rewards.points_source.grade');
|
||||
case 'attendance': return t('rewards.points_source.attendance');
|
||||
case 'homework': return t('rewards.points_source.homework');
|
||||
case 'bonus': return t('rewards.points_source.bonus');
|
||||
default: return source;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeProgress(startDate: string, endDate: string): number {
|
||||
const now = Date.now();
|
||||
const start = new Date(startDate).getTime();
|
||||
@@ -585,6 +745,28 @@ export default function CompetitionsView() {
|
||||
const [myClaims, setMyClaims] = useState<RewardClaimData[]>([]);
|
||||
const [claimingRewardId, setClaimingRewardId] = useState<string | null>(null);
|
||||
|
||||
// Digital Reward Catalog
|
||||
const [catalogRewards, setCatalogRewards] = useState<CatalogReward[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [catalogCategory, setCatalogCategory] = useState<string>('all');
|
||||
const [pointsBalance, setPointsBalance] = useState(0);
|
||||
const [pointsEarned, setPointsEarned] = useState(0);
|
||||
const [pointsSpent, setPointsSpent] = useState(0);
|
||||
const [pointsHistory, setPointsHistory] = useState<PointsHistoryEntry[]>([]);
|
||||
const [myRedemptions, setMyRedemptions] = useState<RedemptionEntry[]>([]);
|
||||
const [redeemDialogOpen, setRedeemDialogOpen] = useState(false);
|
||||
const [redeemTarget, setRedeemTarget] = useState<CatalogReward | null>(null);
|
||||
const [isRedeeming, setIsRedeeming] = useState(false);
|
||||
const [createRewardOpen, setCreateRewardOpen] = useState(false);
|
||||
const [newRewardForm, setNewRewardForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
category: 'streaming',
|
||||
pointsCost: '',
|
||||
stock: '',
|
||||
});
|
||||
const [isCreatingReward, setIsCreatingReward] = useState(false);
|
||||
|
||||
// Register
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
@@ -596,11 +778,16 @@ export default function CompetitionsView() {
|
||||
|
||||
/* ── Fetch competitions ──────────────────────────────────────── */
|
||||
const loadCompetitions = useCallback(async () => {
|
||||
if (!schoolId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100);
|
||||
setCompetitions(result.competitions);
|
||||
if (schoolId) {
|
||||
const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100);
|
||||
setCompetitions(result.competitions);
|
||||
} else {
|
||||
// Users without a schoolId can still see public competitions
|
||||
const result = await fetchPublicCompetitions(100);
|
||||
setCompetitions(result.competitions);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load competitions:', err);
|
||||
toast.error('Failed to load competitions');
|
||||
@@ -615,9 +802,8 @@ export default function CompetitionsView() {
|
||||
|
||||
/* ── Fetch my claims ─────────────────────────────────────────── */
|
||||
const loadMyClaims = useCallback(async () => {
|
||||
if (!schoolId) return;
|
||||
try {
|
||||
const result = await fetchRewardClaims(schoolId, undefined, undefined);
|
||||
const result = await fetchRewardClaims(schoolId ?? undefined, undefined, undefined);
|
||||
setMyClaims(result.claims.filter((c) => c.userId === currentUser?.id));
|
||||
} catch {
|
||||
// silently fail
|
||||
@@ -628,6 +814,130 @@ export default function CompetitionsView() {
|
||||
loadMyClaims();
|
||||
}, [loadMyClaims]);
|
||||
|
||||
/* ── Fetch reward catalog & points ───────────────────────────── */
|
||||
const loadCatalogData = useCallback(async () => {
|
||||
setCatalogLoading(true);
|
||||
try {
|
||||
const [rewardsRes, pointsRes] = await Promise.all([
|
||||
fetch('/api/rewards').catch(() => null),
|
||||
fetch('/api/reward-points').catch(() => null),
|
||||
]);
|
||||
|
||||
if (rewardsRes?.ok) {
|
||||
const rewardsData = await rewardsRes.json();
|
||||
if (Array.isArray(rewardsData) && rewardsData.length > 0) {
|
||||
setCatalogRewards(rewardsData);
|
||||
} else {
|
||||
// Use demo rewards when no real rewards exist
|
||||
setCatalogRewards(DEMO_REWARDS);
|
||||
}
|
||||
} else {
|
||||
setCatalogRewards(DEMO_REWARDS);
|
||||
}
|
||||
|
||||
if (pointsRes?.ok) {
|
||||
const pointsData = await pointsRes.json();
|
||||
setPointsBalance(pointsData.balance ?? 0);
|
||||
setPointsEarned(pointsData.totalEarned ?? 0);
|
||||
setPointsSpent(pointsData.totalSpent ?? 0);
|
||||
setPointsHistory(pointsData.pointsHistory ?? []);
|
||||
setMyRedemptions(pointsData.redemptions ?? []);
|
||||
} else {
|
||||
// Demo data
|
||||
setPointsBalance(750);
|
||||
setPointsEarned(1000);
|
||||
setPointsSpent(250);
|
||||
setPointsHistory([
|
||||
{ id: 'ph1', points: 200, source: 'competition', description: 'Wettbewerb gewonnen', createdAt: new Date(Date.now() - 86400000).toISOString() },
|
||||
{ id: 'ph2', points: 100, source: 'attendance', description: '7 Tage Anwesenheitssträhne', createdAt: new Date(Date.now() - 172800000).toISOString() },
|
||||
{ id: 'ph3', points: 50, source: 'homework', description: 'Hausaufgaben pünktlich abgegeben', createdAt: new Date(Date.now() - 259200000).toISOString() },
|
||||
{ id: 'ph4', points: 150, source: 'grade', description: 'Sehr gut in Mathematik', createdAt: new Date(Date.now() - 345600000).toISOString() },
|
||||
{ id: 'ph5', points: 500, source: 'bonus', description: 'Klassensprecher', createdAt: new Date(Date.now() - 432000000).toISOString() },
|
||||
]);
|
||||
setMyRedemptions([]);
|
||||
}
|
||||
} catch {
|
||||
setCatalogRewards(DEMO_REWARDS);
|
||||
setPointsBalance(750);
|
||||
setPointsEarned(1000);
|
||||
setPointsSpent(250);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalogData();
|
||||
}, [loadCatalogData]);
|
||||
|
||||
/* ── Redeem a reward ─────────────────────────────────────────── */
|
||||
const handleRedeemReward = useCallback(async () => {
|
||||
if (!redeemTarget) return;
|
||||
setIsRedeeming(true);
|
||||
try {
|
||||
const res = await fetch('/api/rewards/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rewardId: redeemTarget.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.error === 'Insufficient points') {
|
||||
toast.error(t('rewards.insufficient_points'));
|
||||
} else if (data.error === 'Out of stock') {
|
||||
toast.error(t('rewards.out_of_stock'));
|
||||
} else {
|
||||
toast.error(data.error || t('rewards.redeem_error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.success(t('rewards.redeem_success'));
|
||||
setRedeemDialogOpen(false);
|
||||
setRedeemTarget(null);
|
||||
loadCatalogData();
|
||||
} catch {
|
||||
toast.error(t('rewards.redeem_error'));
|
||||
} finally {
|
||||
setIsRedeeming(false);
|
||||
}
|
||||
}, [redeemTarget, loadCatalogData]);
|
||||
|
||||
/* ── Create a new reward (admin) ─────────────────────────────── */
|
||||
const handleCreateReward = useCallback(async () => {
|
||||
if (!newRewardForm.title.trim() || !newRewardForm.pointsCost) {
|
||||
toast.error('Title and points cost are required');
|
||||
return;
|
||||
}
|
||||
setIsCreatingReward(true);
|
||||
try {
|
||||
const res = await fetch('/api/rewards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newRewardForm.title.trim(),
|
||||
description: newRewardForm.description.trim() || null,
|
||||
category: newRewardForm.category,
|
||||
pointsCost: parseInt(newRewardForm.pointsCost, 10),
|
||||
stock: newRewardForm.stock ? parseInt(newRewardForm.stock, 10) : null,
|
||||
isDemo: true,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || t('rewards.redeem_error'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('rewards.create_reward'));
|
||||
setCreateRewardOpen(false);
|
||||
setNewRewardForm({ title: '', description: '', category: 'streaming', pointsCost: '', stock: '' });
|
||||
loadCatalogData();
|
||||
} catch {
|
||||
toast.error(t('rewards.redeem_error'));
|
||||
} finally {
|
||||
setIsCreatingReward(false);
|
||||
}
|
||||
}, [newRewardForm, loadCatalogData]);
|
||||
|
||||
/* ── Filtered competitions ───────────────────────────────────── */
|
||||
const filteredCompetitions = useMemo(() => {
|
||||
let result = competitions;
|
||||
@@ -917,6 +1227,7 @@ export default function CompetitionsView() {
|
||||
<TabsTrigger value="my" className="min-h-[44px]">{t('competition.manage')}</TabsTrigger>
|
||||
<TabsTrigger value="leaderboard" className="min-h-[44px]">{t('competition.leaderboard')}</TabsTrigger>
|
||||
<TabsTrigger value="rewards" className="min-h-[44px]">{t('competition.rewards')}</TabsTrigger>
|
||||
<TabsTrigger value="catalog" className="min-h-[44px]">{t('rewards.title')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
@@ -1213,6 +1524,283 @@ export default function CompetitionsView() {
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Digital Reward Catalog Tab ─────────────────────────────── */}
|
||||
<TabsContent value="catalog" className="space-y-6 mt-4">
|
||||
{/* Points Balance Header */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 shadow-lg">
|
||||
<Wallet className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t('rewards.points_balance')}</p>
|
||||
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-muted-foreground">{t('rewards.points_earned')}:</span>
|
||||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">{pointsEarned}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-muted-foreground">{t('rewards.points_spent')}:</span>
|
||||
<span className="font-semibold text-amber-600 dark:text-amber-400">{pointsSpent}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Filter + Create Button */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={catalogCategory === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`min-h-[40px] rounded-lg ${catalogCategory === 'all' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
|
||||
onClick={() => setCatalogCategory('all')}
|
||||
>
|
||||
{t('rewards.category.all')}
|
||||
</Button>
|
||||
{REWARD_CATEGORIES.map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={catalogCategory === cat ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={`min-h-[40px] rounded-lg ${catalogCategory === cat ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
|
||||
onClick={() => setCatalogCategory(cat)}
|
||||
>
|
||||
{getCategoryIcon(cat, 'h-4 w-4 mr-1')}
|
||||
{getCategoryLabelReward(cat)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
className="min-h-[44px] bg-amber-600 hover:bg-amber-700"
|
||||
onClick={() => setCreateRewardOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('rewards.create_reward')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reward Catalog Grid */}
|
||||
{catalogLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Card key={i}><CardContent className="p-4"><Skeleton className="h-40" /></CardContent></Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{catalogRewards
|
||||
.filter((r) => catalogCategory === 'all' || r.category === catalogCategory)
|
||||
.map((reward, idx) => (
|
||||
<motion.div
|
||||
key={reward.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.2, delay: idx * 0.03 }}
|
||||
whileHover={{ y: -2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className={`border-l-4 ${getCategoryBorderColor(reward.category)} h-full flex flex-col transition-shadow hover:shadow-md`}>
|
||||
<CardHeader className={`pb-3 bg-gradient-to-r ${getCategoryBgGradient(reward.category)} to-transparent`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${getCategoryColor(reward.category)} shrink-0`}>
|
||||
{getCategoryIcon(reward.category, 'h-5 w-5')}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-sm leading-tight truncate">{reward.title}</CardTitle>
|
||||
<Badge className={`${getCategoryColor(reward.category)} text-xs mt-1`}>
|
||||
{getCategoryLabelReward(reward.category)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400 shrink-0">
|
||||
<Coins className="h-4 w-4" />
|
||||
<span className="font-bold text-sm">{reward.pointsCost}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 pb-3 space-y-3">
|
||||
{reward.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{reward.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{reward.stock !== null && reward.stock !== undefined
|
||||
? t('rewards.stock_remaining').replace('{count}', String(reward.stock))
|
||||
: t('rewards.unlimited')}
|
||||
</span>
|
||||
{pointsBalance >= reward.pointsCost ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-rose-500 dark:text-rose-400 flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className={`w-full min-h-[40px] rounded-lg ${
|
||||
pointsBalance >= reward.pointsCost
|
||||
? 'bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={pointsBalance < reward.pointsCost}
|
||||
onClick={() => {
|
||||
setRedeemTarget(reward);
|
||||
setRedeemDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Gift className="h-4 w-4 mr-1" />
|
||||
{t('rewards.redeem')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* Earn Points Section */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<Zap className="h-4 w-4" />
|
||||
</div>
|
||||
{t('rewards.earn_points')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('rewards.earn_points_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ icon: Trophy, label: t('rewards.earn_competition'), color: 'emerald' },
|
||||
{ icon: Star, label: t('rewards.earn_grades'), color: 'amber' },
|
||||
{ icon: Calendar, label: t('rewards.earn_attendance'), color: 'teal' },
|
||||
{ icon: BookOpen, label: t('rewards.earn_homework'), color: 'violet' },
|
||||
{ icon: Sparkles, label: t('rewards.earn_bonus'), color: 'rose' },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
const colorMap: Record<string, { bg: string; text: string }> = {
|
||||
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400' },
|
||||
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
|
||||
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400' },
|
||||
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400' },
|
||||
rose: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400' },
|
||||
};
|
||||
const c = colorMap[item.color] || colorMap.emerald;
|
||||
return (
|
||||
<div key={item.label} className={`p-3 rounded-xl ${c.bg}`}>
|
||||
<Icon className={`h-5 w-5 ${c.text} mb-2`} />
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-300">{item.label}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Points History & My Redemptions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Points History */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<History className="h-4 w-4" />
|
||||
</div>
|
||||
{t('rewards.points_history')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-96 overflow-y-auto">
|
||||
{pointsHistory.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<History className="h-8 w-8 mb-2 opacity-30" />
|
||||
<p className="text-xs">{t('rewards.no_rewards')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pointsHistory.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-100 dark:bg-emerald-900/30 shrink-0">
|
||||
<Plus className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">{entry.description || getPointsSourceLabel(entry.source)}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(entry.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">+{entry.points}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* My Redemptions */}
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<Gift className="h-4 w-4" />
|
||||
</div>
|
||||
{t('rewards.my_redemptions')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-96 overflow-y-auto">
|
||||
{myRedemptions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Gift className="h-8 w-8 mb-2 opacity-30" />
|
||||
<p className="text-xs">{t('rewards.no_rewards')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{myRedemptions.map((redemption) => (
|
||||
<div key={redemption.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-amber-100 dark:bg-amber-900/30 shrink-0">
|
||||
{getCategoryIcon(redemption.reward?.category ?? 'streaming', 'h-3 w-3 text-amber-600 dark:text-amber-400')}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">{redemption.reward?.title ?? 'Reward'}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(redemption.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className={`${getRedemptionStatusColor(redemption.status)} text-xs`}>
|
||||
{getRedemptionStatusLabel(redemption.status)}
|
||||
</Badge>
|
||||
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">-{redemption.pointsSpent}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Competition Detail Dialog ──────────────────────────── */}
|
||||
@@ -1716,6 +2304,160 @@ export default function CompetitionsView() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Redeem Reward Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={redeemDialogOpen} onOpenChange={setRedeemDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Gift className="h-5 w-5 text-amber-500" />
|
||||
{t('rewards.redeem_confirm')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{redeemTarget
|
||||
? t('rewards.redeem_confirm_desc')
|
||||
.replace('{points}', String(redeemTarget.pointsCost))
|
||||
.replace('{reward}', redeemTarget.title)
|
||||
: ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{redeemTarget && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${getCategoryColor(redeemTarget.category)} shrink-0`}>
|
||||
{getCategoryIcon(redeemTarget.category, 'h-6 w-6')}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{redeemTarget.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={getCategoryColor(redeemTarget.category)}>
|
||||
{getCategoryLabelReward(redeemTarget.category)}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||
<Coins className="h-3 w-3" />
|
||||
{redeemTarget.pointsCost}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<span className="text-sm text-muted-foreground">{t('rewards.points_balance')}</span>
|
||||
<span className="font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance}</span>
|
||||
</div>
|
||||
{pointsBalance >= redeemTarget.pointsCost && (
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10">
|
||||
<span className="text-sm text-muted-foreground">After redemption:</span>
|
||||
<span className="font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance - redeemTarget.pointsCost}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="min-h-[44px]" onClick={() => setRedeemDialogOpen(false)}>
|
||||
{t('action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
||||
onClick={handleRedeemReward}
|
||||
disabled={isRedeeming || !redeemTarget || pointsBalance < (redeemTarget?.pointsCost ?? Infinity)}
|
||||
>
|
||||
{isRedeeming ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Gift className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('rewards.redeem')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Create Reward Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={createRewardOpen} onOpenChange={setCreateRewardOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5 text-amber-500" />
|
||||
{t('rewards.create_reward')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('rewards.create_reward')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div>
|
||||
<Label>{t('competition.name')} *</Label>
|
||||
<Input
|
||||
value={newRewardForm.title}
|
||||
onChange={(e) => setNewRewardForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Reward title"
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('competition.description')}</Label>
|
||||
<Input
|
||||
value={newRewardForm.description}
|
||||
onChange={(e) => setNewRewardForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Description..."
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('competition.category')}</Label>
|
||||
<Select value={newRewardForm.category} onValueChange={(v) => setNewRewardForm((f) => ({ ...f, category: v }))}>
|
||||
<SelectTrigger className="mt-1 min-h-[44px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REWARD_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{getCategoryLabelReward(cat)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>{t('rewards.points_cost')} *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newRewardForm.pointsCost}
|
||||
onChange={(e) => setNewRewardForm((f) => ({ ...f, pointsCost: e.target.value }))}
|
||||
placeholder="100"
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Stock</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newRewardForm.stock}
|
||||
onChange={(e) => setNewRewardForm((f) => ({ ...f, stock: e.target.value }))}
|
||||
placeholder="Unlimited"
|
||||
className="mt-1 min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="min-h-[44px]" onClick={() => setCreateRewardOpen(false)}>
|
||||
{t('action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-[44px] bg-amber-600 hover:bg-amber-700"
|
||||
onClick={handleCreateReward}
|
||||
disabled={isCreatingReward}
|
||||
>
|
||||
{isCreatingReward ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('rewards.create_reward')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ import {
|
||||
PenSquare,
|
||||
Eye,
|
||||
Tag,
|
||||
Shield,
|
||||
Database,
|
||||
Server,
|
||||
Cpu,
|
||||
UserPlus,
|
||||
School,
|
||||
LayoutList,
|
||||
Activity as ActivityIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
TrendingDown,
|
||||
BarChart2,
|
||||
PieChart as PieChartIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -74,6 +87,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -91,6 +105,24 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -476,6 +508,499 @@ function ParentDashboard({ currentUser, setCurrentView, locale }: {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Parent Children's Recent Grades */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||
<Calculator className="h-4 w-4" />
|
||||
</div>
|
||||
{t('parent.children_grades')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{children.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{children.map((child) => (
|
||||
<div key={child.studentId} className="p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-violet-400 to-purple-500 text-white text-[10px] font-bold shrink-0">
|
||||
{child.firstName[0]}{child.lastName[0]}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{child.firstName} {child.lastName}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 ml-auto">{child.className}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
|
||||
{[
|
||||
{ subject: t('student.notebook_math'), grade: '2.3' },
|
||||
{ subject: t('student.notebook_german'), grade: '1.8' },
|
||||
{ subject: t('student.notebook_english'), grade: '2.7' },
|
||||
{ subject: t('student.notebook_science'), grade: '2.1' },
|
||||
].map((g) => (
|
||||
<div key={g.subject} className="px-2 py-1.5 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100/50 dark:border-emerald-900/20 text-center">
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{g.subject}</p>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{g.grade}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
<Calculator 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('parent.no_children')}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Parent Competition Results & Calendar Events */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Competition Results */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
|
||||
<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-yellow-500 text-white shadow-sm">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('parent.competition_results')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-amber-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-amber-50 to-yellow-50/50 dark:from-amber-900/20 dark:to-yellow-900/10 border border-amber-200/30 dark:border-amber-900/20">
|
||||
<div className="text-center">
|
||||
<Trophy className="h-8 w-8 text-amber-400/60 dark:text-amber-600/40 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent.no_competition_results')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Calendar Events */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full 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-violet-400 to-purple-500 text-white shadow-sm">
|
||||
<CalendarIconNav className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('parent.calendar_events')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ title: locale === 'de' ? 'Elternsprechtag' : 'Parent-Teacher Day', date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), color: 'from-emerald-400 to-teal-500' },
|
||||
{ title: locale === 'de' ? 'Schulfest' : 'School Festival', date: new Date(Date.now() + 12 * 24 * 60 * 60 * 1000), color: 'from-amber-400 to-orange-500' },
|
||||
{ title: locale === 'de' ? 'Zeugnisausgabe' : 'Report Card Day', date: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000), color: 'from-violet-400 to-purple-500' },
|
||||
].map((event) => {
|
||||
const daysUntil = Math.ceil((event.date.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return (
|
||||
<div key={event.title} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/50 dark:border-gray-800/20 min-h-[44px]">
|
||||
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${event.color} text-white shadow-sm shrink-0`}>
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{event.title}</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500">{event.date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">{daysUntil}d</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Parent Announcements */}
|
||||
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── School Admin Dashboard Component ──────────────────────────────────
|
||||
function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
|
||||
currentUser: CurrentUser;
|
||||
setCurrentView: (view: ViewName) => void;
|
||||
locale: string;
|
||||
data: DashboardData;
|
||||
}) {
|
||||
// Mock audit log data
|
||||
const auditLogEntries = [
|
||||
{ id: '1', action: 'USER_LOGIN', user: 'Max Mustermann', timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(), details: 'Teacher login' },
|
||||
{ id: '2', action: 'ASSESSMENT_CREATED', user: 'Anna Schmidt', timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), details: 'Math assessment created' },
|
||||
{ id: '3', action: 'STUDENT_ENROLLED', user: 'System', timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(), details: 'New student enrolled' },
|
||||
{ id: '4', action: 'REPORT_GENERATED', user: 'Peter Weber', timestamp: new Date(Date.now() - 1000 * 60 * 120).toISOString(), details: 'Class report generated' },
|
||||
{ id: '5', action: 'SETTINGS_UPDATED', user: 'Admin', timestamp: new Date(Date.now() - 1000 * 60 * 180).toISOString(), details: 'School settings updated' },
|
||||
];
|
||||
|
||||
// Mock system health data
|
||||
const systemHealth = {
|
||||
status: 'healthy' as const,
|
||||
uptime: '99.9%',
|
||||
activeUsers: Math.floor(data.stats.totalStudents * 0.3 + data.stats.totalClasses * 2),
|
||||
storageUsed: 42,
|
||||
apiRequestsToday: 1247,
|
||||
};
|
||||
|
||||
// Enrollment trend data for chart
|
||||
const enrollmentData = [
|
||||
{ month: locale === 'de' ? 'Jan' : 'Jan', students: Math.floor(data.stats.totalStudents * 0.85) },
|
||||
{ month: locale === 'de' ? 'Feb' : 'Feb', students: Math.floor(data.stats.totalStudents * 0.87) },
|
||||
{ month: locale === 'de' ? 'Mär' : 'Mar', students: Math.floor(data.stats.totalStudents * 0.9) },
|
||||
{ month: locale === 'de' ? 'Apr' : 'Apr', students: Math.floor(data.stats.totalStudents * 0.92) },
|
||||
{ month: locale === 'de' ? 'Mai' : 'May', students: Math.floor(data.stats.totalStudents * 0.95) },
|
||||
{ month: locale === 'de' ? 'Jun' : 'Jun', students: data.stats.totalStudents },
|
||||
];
|
||||
|
||||
// Grade distribution data for pie chart
|
||||
const gradeDistribution = [
|
||||
{ name: '1', value: 25, color: '#10b981' },
|
||||
{ name: '2', value: 35, color: '#14b8a6' },
|
||||
{ name: '3', value: 25, color: '#f59e0b' },
|
||||
{ name: '4', value: 12, color: '#f97316' },
|
||||
{ name: '5-6', value: 3, color: '#ef4444' },
|
||||
];
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
switch (action) {
|
||||
case 'USER_LOGIN': return UserCheck;
|
||||
case 'ASSESSMENT_CREATED': return ClipboardCheck;
|
||||
case 'STUDENT_ENROLLED': return UserPlus;
|
||||
case 'REPORT_GENERATED': return FileText;
|
||||
case 'SETTINGS_UPDATED': return Settings;
|
||||
default: return Activity;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
switch (action) {
|
||||
case 'USER_LOGIN': return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400';
|
||||
case 'ASSESSMENT_CREATED': return 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400';
|
||||
case 'STUDENT_ENROLLED': return 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400';
|
||||
case 'REPORT_GENERATED': return 'bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400';
|
||||
case 'SETTINGS_UPDATED': return 'bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400';
|
||||
default: return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const relativeTime = (dateStr: string) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffMins < 1) return locale === 'de' ? 'Gerade eben' : 'Just now';
|
||||
if (diffMins < 60) return locale === 'de' ? `Vor ${diffMins} Min.` : `${diffMins}m ago`;
|
||||
if (diffHours < 24) return locale === 'de' ? `Vor ${diffHours} Std.` : `${diffHours}h ago`;
|
||||
if (diffDays === 1) return t('date.yesterday');
|
||||
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
|
||||
return date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short' });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="space-y-6 pb-8"
|
||||
>
|
||||
{/* Admin 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-rose-400 to-orange-500 text-white shadow-lg shadow-rose-300/30 ring-2 ring-rose-200/30 dark:ring-rose-800/20">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold bg-gradient-to-r from-rose-600 via-orange-600 to-amber-500 dark:from-rose-400 dark:via-orange-400 dark:to-amber-300 bg-clip-text text-transparent">
|
||||
{t('admin.welcome_back')}, {currentUser.firstName}!
|
||||
</h2>
|
||||
<p className="text-rose-600/60 dark:text-rose-400/40 mt-0.5 text-sm">{t('admin.dashboard_title')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-rose-50 to-orange-50/50 dark:from-rose-900/20 dark:to-orange-900/10 border border-rose-200/40 dark:border-rose-900/30 text-xs font-medium text-rose-700 dark:text-rose-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>
|
||||
|
||||
{/* School Overview Stats */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ icon: Users, label: t('admin.total_students'), value: data.stats.totalStudents, color: 'from-emerald-400 to-teal-500', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200/40 dark:border-emerald-900/30' },
|
||||
{ icon: BarChart3, label: t('admin.total_classes'), value: data.stats.totalClasses, color: 'from-teal-400 to-cyan-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' },
|
||||
{ icon: GraduationCap, label: t('admin.total_teachers'), value: Math.max(1, Math.floor(data.stats.totalClasses * 0.8)), color: 'from-amber-400 to-orange-500', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200/40 dark:border-amber-900/30' },
|
||||
{ icon: Heart, label: t('admin.total_parents'), value: Math.floor(data.stats.totalStudents * 1.4), color: 'from-rose-400 to-pink-500', bg: 'bg-rose-50 dark:bg-rose-900/20', border: 'border-rose-200/40 dark:border-rose-900/30' },
|
||||
].map((stat) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
whileHover={{ scale: 1.03, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<Card className={`border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] ${stat.bg} ${stat.border}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} text-white shadow-sm shrink-0`}>
|
||||
<stat.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 leading-none mt-0.5"><CountUp target={stat.value} /></p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* System Health Card */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
|
||||
<Server className="h-4 w-4" />
|
||||
</div>
|
||||
{t('admin.system_health')}
|
||||
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs shadow-sm">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{t('admin.health_excellent')}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ icon: ActivityIcon, label: t('admin.uptime'), value: systemHealth.uptime, color: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ icon: Users, label: t('admin.active_users'), value: systemHealth.activeUsers.toString(), color: 'text-teal-600 dark:text-teal-400' },
|
||||
{ icon: Database, label: t('admin.storage_used'), value: `${systemHealth.storageUsed}%`, color: 'text-amber-600 dark:text-amber-400' },
|
||||
{ icon: Cpu, label: t('admin.api_requests'), value: systemHealth.apiRequestsToday.toLocaleString(), color: 'text-violet-600 dark:text-violet-400' },
|
||||
].map((metric) => (
|
||||
<div key={metric.label} className="p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<metric.icon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{metric.label}</span>
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${metric.color}`}>{metric.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Storage usage bar */}
|
||||
<div className="mt-4 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">{t('admin.storage_used')}</span>
|
||||
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">{systemHealth.storageUsed}%</span>
|
||||
</div>
|
||||
<Progress value={systemHealth.storageUsed} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* School Statistics Charts */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm">
|
||||
<BarChart2 className="h-4 w-4" />
|
||||
</div>
|
||||
{t('admin.school_statistics')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Enrollment Trend */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('admin.enrollment_trend')}</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={enrollmentData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="#9ca3af" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
|
||||
/>
|
||||
<Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Grade Distribution */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('admin.grade_distribution')}</h4>
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width="60%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={gradeDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{gradeDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-2">
|
||||
{gradeDistribution.map((grade) => (
|
||||
<div key={grade.name} className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: grade.color }} />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">{grade.name}: {grade.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Audit Log & User Management */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Audit Log */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
|
||||
<LayoutList className="h-4 w-4" />
|
||||
</div>
|
||||
{t('admin.audit_log')}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('admin.audit_log_desc')}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{auditLogEntries.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<LayoutList className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('admin.no_audit_entries')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto scrollbar-education pr-1">
|
||||
{auditLogEntries.map((entry) => {
|
||||
const ActionIcon = getActionIcon(entry.action);
|
||||
return (
|
||||
<div key={entry.id} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${getActionColor(entry.action)} shrink-0`}>
|
||||
<ActionIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{entry.details}</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{entry.user} · {relativeTime(entry.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">{entry.action.replace('_', ' ')}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* User Management Quick Actions */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</div>
|
||||
{t('admin.user_management')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ icon: GraduationCap, label: t('admin.manage_teachers'), desc: t('admin.add_teacher'), color: 'from-amber-400 to-orange-500', view: 'settings' as ViewName },
|
||||
{ icon: Users, label: t('admin.manage_students'), desc: t('admin.add_student'), color: 'from-emerald-400 to-teal-500', view: 'classes' as ViewName },
|
||||
{ icon: Heart, label: t('admin.manage_parents'), desc: t('admin.manage_parents'), color: 'from-rose-400 to-pink-500', view: 'parents' as ViewName },
|
||||
{ icon: School, label: t('admin.school_settings'), desc: t('admin.school_settings'), color: 'from-violet-400 to-purple-500', view: 'settings' as ViewName },
|
||||
].map((action) => (
|
||||
<motion.button
|
||||
key={action.label}
|
||||
onClick={() => setCurrentView(action.view)}
|
||||
whileHover={{ scale: 1.01, x: 2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-teal-200/60 dark:hover:border-teal-800/30 transition-all min-h-[44px]"
|
||||
>
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${action.color} text-white shadow-sm shrink-0 transition-transform duration-200 group-hover:scale-110`}>
|
||||
<action.icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{action.label}</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{action.desc}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-teal-500 transition-colors shrink-0" />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setCurrentView('classes')}
|
||||
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white min-h-[44px] px-4 font-semibold shadow-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{t('admin.add_class')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentView('settings')}
|
||||
className="border-teal-300 dark:border-teal-700 hover:bg-teal-50 dark:hover:bg-teal-900/20 text-teal-700 dark:text-teal-300 min-h-[44px] px-4"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1.5" />
|
||||
{t('admin.school_settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Announcements Management */}
|
||||
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
|
||||
|
||||
{/* School Newsletter */}
|
||||
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1024,6 +1549,75 @@ export default function DashboardView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Student Grades Trend Chart */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<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-orange-500 text-white shadow-sm">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.my_grades_trend')}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{t('student.grade_trend_up')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-1">
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={[
|
||||
{ subject: t('student.notebook_math').substring(0, 3), grade: 2.3 },
|
||||
{ subject: t('student.notebook_german').substring(0, 3), grade: 1.8 },
|
||||
{ subject: t('student.notebook_english').substring(0, 3), grade: 2.7 },
|
||||
{ subject: t('student.notebook_science').substring(0, 3), grade: 2.1 },
|
||||
]}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="subject" tick={{ fontSize: 10 }} stroke="#9ca3af" />
|
||||
<YAxis domain={[1, 6]} reversed tick={{ fontSize: 10 }} stroke="#9ca3af" />
|
||||
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
|
||||
<Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Student Competition Standings */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]" onClick={() => setCurrentView('competitions')}>
|
||||
<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-yellow-500 text-white shadow-sm">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">{t('student.competition_standings')}</CardTitle>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="flex items-center justify-center h-20 rounded-lg bg-gradient-to-br from-amber-50 to-yellow-50/50 dark:from-amber-900/20 dark:to-yellow-900/10 border border-amber-200/30 dark:border-amber-900/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
|
||||
<Trophy className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-amber-600 dark:text-amber-400">#12</p>
|
||||
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student.my_rank')} · 245 {t('student.points')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Student School Announcements */}
|
||||
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1039,6 +1633,18 @@ export default function DashboardView() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── School Admin Dashboard ─────────────────────────────────────────
|
||||
if (currentUser?.role === 'SCHOOL_ADMIN') {
|
||||
return (
|
||||
<SchoolAdminDashboard
|
||||
currentUser={currentUser}
|
||||
setCurrentView={setCurrentView}
|
||||
locale={locale}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
@@ -1209,7 +1815,7 @@ export default function DashboardView() {
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Card className={`card-hover-lift border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
|
||||
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
|
||||
<CardContent className={`p-5 ${colors.bg}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0">
|
||||
@@ -1426,6 +2032,94 @@ export default function DashboardView() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== TEACHER COMPETENCE PROGRESS RADAR ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
|
||||
<Flower2 className="h-4 w-4" />
|
||||
</div>
|
||||
{t('teacher.competence_progress')}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('teacher.competence_progress_desc')}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RadarChart data={[
|
||||
{ subject: t('student.notebook_math'), current: 3.5, target: 4 },
|
||||
{ subject: t('student.notebook_german'), current: 3.8, target: 4 },
|
||||
{ subject: t('student.notebook_english'), current: 2.9, target: 4 },
|
||||
{ subject: t('student.notebook_science'), current: 3.2, target: 4 },
|
||||
{ subject: locale === 'de' ? 'Musik' : 'Music', current: 3.6, target: 4 },
|
||||
{ subject: locale === 'de' ? 'Sport' : 'PE', current: 4.0, target: 4 },
|
||||
]}>
|
||||
<PolarGrid stroke="#e5e7eb" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 4]} tick={{ fontSize: 10 }} />
|
||||
<Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} />
|
||||
<Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" />
|
||||
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== UPCOMING LESSONS ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
{t('teacher.upcoming_lessons')}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">
|
||||
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.classesOverview.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<Clock className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('teacher.no_lessons')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.classesOverview.slice(0, 4).map((cls, idx) => {
|
||||
const times = ['08:00 – 08:45', '09:00 – 09:45', '10:00 – 10:45', '11:00 – 11:45'];
|
||||
const subjects = [t('student.notebook_math'), t('student.notebook_german'), t('student.notebook_english'), t('student.notebook_science')];
|
||||
const colors = ['from-amber-400 to-orange-500', 'from-emerald-400 to-teal-500', 'from-rose-400 to-pink-500', 'from-violet-400 to-purple-500'];
|
||||
return (
|
||||
<motion.div
|
||||
key={cls.id}
|
||||
whileHover={{ scale: 1.01, x: 2 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="group flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-violet-200/60 dark:hover:border-violet-800/30 transition-colors min-h-[44px]"
|
||||
>
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${colors[idx % colors.length]} text-white shadow-sm shrink-0`}>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{subjects[idx % subjects.length]} — {cls.name}</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400">{times[idx % times.length]} · {t('teacher.student_count')}: {cls.studentCount}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{times[idx % times.length].split(' – ')[0]}</Badge>
|
||||
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-violet-500 transition-colors" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== TODAY'S SCHEDULE ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
|
||||
@@ -1808,7 +2502,7 @@ export default function DashboardView() {
|
||||
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
|
||||
<div className="activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
|
||||
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry) => {
|
||||
const catColor = entry.competency.category.color ?? '#10b981';
|
||||
const studentName = `${entry.student.firstName} ${entry.student.lastName}`;
|
||||
@@ -1818,7 +2512,7 @@ export default function DashboardView() {
|
||||
key={entry.id}
|
||||
whileHover={{ scale: 1.01, x: 2 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="group flex items-center justify-between p-3.5 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-colors relative overflow-hidden"
|
||||
className="activity-timeline-item group flex items-center justify-between p-3.5 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-colors relative overflow-hidden"
|
||||
>
|
||||
{/* Left border color indicator */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl transition-all duration-200 group-hover:w-1.5" style={{ backgroundColor: catColor }} />
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Activity,
|
||||
Highlighter,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
@@ -73,7 +74,7 @@ export interface StrokePoint {
|
||||
|
||||
export interface Stroke {
|
||||
id: string;
|
||||
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser';
|
||||
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
|
||||
color: string;
|
||||
width: number;
|
||||
points: StrokePoint[];
|
||||
@@ -93,7 +94,7 @@ export interface DrawingCanvasProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser';
|
||||
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
|
||||
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted';
|
||||
type GuideMode = 'off' | 'basic' | 'circles' | 'perspective';
|
||||
|
||||
@@ -111,6 +112,11 @@ const PEN_COLORS = [
|
||||
'#533483', '#2b2d42', '#8d99ae', '#ef233c',
|
||||
];
|
||||
|
||||
const HIGHLIGHTER_COLORS = [
|
||||
'#fef08a', '#bbf7d0', '#bfdbfe', '#fbcfe8',
|
||||
'#fed7aa', '#c4b5fd', '#99f6e4', '#fca5a5',
|
||||
];
|
||||
|
||||
const AUTO_SAVE_INTERVAL = 30000;
|
||||
const AUTO_SAVE_DEBOUNCE = 5000; // Debounced auto-save: 5s after last stroke
|
||||
const LOCAL_STORAGE_KEY = 'ct_drawing_draft';
|
||||
@@ -355,6 +361,11 @@ function drawStroke(
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
ctx.lineWidth = stroke.width * 2;
|
||||
} else if (stroke.tool === 'highlighter') {
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width * 4;
|
||||
ctx.globalAlpha = 0.35;
|
||||
} else {
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width;
|
||||
@@ -364,7 +375,7 @@ function drawStroke(
|
||||
ctx.globalAlpha = 0.7;
|
||||
}
|
||||
|
||||
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser') {
|
||||
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser' || stroke.tool === 'highlighter') {
|
||||
if (stroke.points.length === 1) {
|
||||
const p = stroke.points[0];
|
||||
const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width;
|
||||
@@ -487,6 +498,16 @@ export default function DrawingCanvas({
|
||||
const [bgType, setBgType] = useState<BackgroundType>(backgroundType);
|
||||
const [guideMode, setGuideMode] = useState<GuideMode>('off');
|
||||
|
||||
// Set default color for highlighter
|
||||
const handleToolChange = useCallback((tool: ToolType) => {
|
||||
setActiveTool(tool);
|
||||
if (tool === 'highlighter' && strokeColor === '#000000') {
|
||||
setStrokeColor('#fef08a');
|
||||
} else if (tool !== 'highlighter' && strokeColor === '#fef08a') {
|
||||
setStrokeColor('#000000');
|
||||
}
|
||||
}, [strokeColor]);
|
||||
|
||||
// UI state
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
@@ -885,6 +906,7 @@ export default function DrawingCanvas({
|
||||
const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [
|
||||
{ id: 'pencil', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_pencil') },
|
||||
{ id: 'pen', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_pen') },
|
||||
{ id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') },
|
||||
{ id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') },
|
||||
{ id: 'rectangle', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_rectangle') },
|
||||
{ id: 'circle', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_circle') },
|
||||
@@ -943,7 +965,7 @@ export default function DrawingCanvas({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveTool(tool.id)}
|
||||
onClick={() => handleToolChange(tool.id)}
|
||||
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'
|
||||
@@ -997,9 +1019,9 @@ export default function DrawingCanvas({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium text-gray-500">
|
||||
<div className="mt-2 text-xs font-medium text-gray-500">
|
||||
{t('drawing.tool_pen')} {t('drawing.color').toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{PEN_COLORS.map((color) => (
|
||||
<button
|
||||
@@ -1015,6 +1037,28 @@ export default function DrawingCanvas({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{activeTool === 'highlighter' && (
|
||||
<>
|
||||
<p className="mt-2 text-xs font-medium text-gray-500">
|
||||
{t('drawing.tool_highlighter')}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{HIGHLIGHTER_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setStrokeColor(color)}
|
||||
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={`Highlighter color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Label className="text-xs text-gray-500">{t('drawing.color')}</Label>
|
||||
<input
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
|
||||
Users as UsersIcon, Radio, MousePointer2, Columns2,
|
||||
ChevronRight, XCircle, Trophy, Zap, Flame, Dumbbell, Heart, RotateCcw, ArrowRight, Play,
|
||||
StickyNote, LayoutTemplate, Network, GitCompare, CalendarDays,
|
||||
FolderOpen, Search as SearchIcon, ClipboardCopy, Tag,
|
||||
Minus, CircleDot, Sparkles as SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -104,6 +107,101 @@ interface PageVersion {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ─── Sticky Note Type ────────────────────────────────────────────────
|
||||
|
||||
interface StickyNoteData {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
text: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
// ─── Section Type ────────────────────────────────────────────────────
|
||||
|
||||
interface SectionData {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
pageIds: string[];
|
||||
}
|
||||
|
||||
// ─── Page Template Definitions ───────────────────────────────────────
|
||||
|
||||
const PAGE_TEMPLATES: Array<{
|
||||
key: string;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
icon: React.ElementType;
|
||||
background: string;
|
||||
getContent: () => string;
|
||||
}> = [
|
||||
{
|
||||
key: 'blank',
|
||||
titleKey: 'notebooks.page_template_blank',
|
||||
descKey: 'notebooks.type_blank_desc',
|
||||
icon: File,
|
||||
background: 'blank',
|
||||
getContent: () => '',
|
||||
},
|
||||
{
|
||||
key: 'cornell',
|
||||
titleKey: 'notebooks.page_template_cornell',
|
||||
descKey: 'notebooks.page_template_cornell_desc',
|
||||
icon: LayoutTemplate,
|
||||
background: 'lined',
|
||||
getContent: () => `<div style="display:grid;grid-template-columns:1fr 2fr;grid-template-rows:auto 1fr;gap:8px;min-height:500px;border:1px solid #e5e7eb;border-radius:8px;padding:8px;"><div style="border-right:1px solid #e5e7eb;padding-right:8px;font-size:12px;color:#9ca3af;"><b>Stichworte</b><br><br><br></div><div style="padding-left:8px;"><b>Notizen</b><br><br><br></div><div style="grid-column:1/-1;border-top:1px solid #e5e7eb;padding-top:8px;"><b>Zusammenfassung</b><br><br></div></div>`,
|
||||
},
|
||||
{
|
||||
key: 'mindmap',
|
||||
titleKey: 'notebooks.page_template_mindmap',
|
||||
descKey: 'notebooks.page_template_mindmap_desc',
|
||||
icon: Network,
|
||||
background: 'blank',
|
||||
getContent: () => `<div style="display:flex;align-items:center;justify-content:center;min-height:500px;"><div style="position:relative;width:100%;height:100%;"><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:120px;height:120px;border-radius:50%;border:3px solid #10b981;display:flex;align-items:center;justify-content:center;font-weight:bold;color:#10b981;">Zentrales Thema</div><div style="position:absolute;top:20%;left:15%;width:80px;height:80px;border-radius:50%;border:2px solid #3b82f6;display:flex;align-items:center;justify-content:center;font-size:12px;color:#3b82f6;">Idee 1</div><div style="position:absolute;top:20%;right:15%;width:80px;height:80px;border-radius:50%;border:2px solid #ef4444;display:flex;align-items:center;justify-content:center;font-size:12px;color:#ef4444;">Idee 2</div><div style="position:absolute;bottom:20%;left:15%;width:80px;height:80px;border-radius:50%;border:2px solid #f59e0b;display:flex;align-items:center;justify-content:center;font-size:12px;color:#f59e0b;">Idee 3</div><div style="position:absolute;bottom:20%;right:15%;width:80px;height:80px;border-radius:50%;border:2px solid #8b5cf6;display:flex;align-items:center;justify-content:center;font-size:12px;color:#8b5cf6;">Idee 4</div></div></div>`,
|
||||
},
|
||||
{
|
||||
key: 'venn',
|
||||
titleKey: 'notebooks.page_template_venn',
|
||||
descKey: 'notebooks.page_template_venn_desc',
|
||||
icon: GitCompare,
|
||||
background: 'blank',
|
||||
getContent: () => `<div style="display:flex;align-items:center;justify-content:center;min-height:500px;position:relative;"><div style="position:absolute;width:260px;height:260px;border-radius:50%;border:3px solid #3b82f6;background:rgba(59,130,246,0.08);display:flex;align-items:center;justify-content:center;left:calc(50% - 180px);"><span style="font-size:12px;color:#3b82f6;font-weight:bold;">Menge A</span></div><div style="position:absolute;width:260px;height:260px;border-radius:50%;border:3px solid #ef4444;background:rgba(239,68,68,0.08);display:flex;align-items:center;justify-content:center;left:calc(50% - 80px);"><span style="font-size:12px;color:#ef4444;font-weight:bold;">Menge B</span></div><div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:11px;color:#6b7280;font-weight:bold;">Gemeinsam</div></div>`,
|
||||
},
|
||||
{
|
||||
key: 'tchart',
|
||||
titleKey: 'notebooks.page_template_tchart',
|
||||
descKey: 'notebooks.page_template_tchart_desc',
|
||||
icon: Columns2,
|
||||
background: 'lined',
|
||||
getContent: () => `<div style="display:grid;grid-template-columns:1fr 1fr;min-height:500px;border:1px solid #e5e7eb;border-radius:8px;"><div style="border-right:2px solid #10b981;padding:12px;"><b>Seite A</b><br><br><br></div><div style="padding:12px;"><b>Seite B</b><br><br><br></div></div>`,
|
||||
},
|
||||
{
|
||||
key: 'weekly',
|
||||
titleKey: 'notebooks.page_template_weekly',
|
||||
descKey: 'notebooks.page_template_weekly_desc',
|
||||
icon: CalendarDays,
|
||||
background: 'grid',
|
||||
getContent: () => `<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px;min-height:500px;"><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#f0fdf4;"><b>Montag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#eff6ff;"><b>Dienstag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#fef3c7;"><b>Mittwoch</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#fce7f3;"><b>Donnerstag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#f3e8ff;"><b>Freitag</b><br><br></div></div>`,
|
||||
},
|
||||
];
|
||||
|
||||
const STICKY_NOTE_COLORS = [
|
||||
{ key: 'yellow', color: '#fef08a', border: '#fbbf24' },
|
||||
{ key: 'green', color: '#bbf7d0', border: '#34d399' },
|
||||
{ key: 'blue', color: '#bfdbfe', border: '#60a5fa' },
|
||||
{ key: 'pink', color: '#fbcfe8', border: '#f472b6' },
|
||||
{ key: 'orange', color: '#fed7aa', border: '#fb923c' },
|
||||
{ key: 'purple', color: '#ddd6fe', border: '#a78bfa' },
|
||||
];
|
||||
|
||||
const SECTION_COLORS = [
|
||||
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
|
||||
];
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
|
||||
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [
|
||||
@@ -416,7 +514,7 @@ function WysiwygToolbar({ editorRef, onFormatChange }: { editorRef: React.RefObj
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
|
||||
<div className="glass-toolbar flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
|
||||
{/* Format group */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -635,7 +733,7 @@ function NotebookCard({
|
||||
>
|
||||
{/* Cover section */}
|
||||
<div
|
||||
className="relative h-28 flex items-center justify-center overflow-hidden"
|
||||
className="notebook-cover relative h-28 flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${notebook.color}, ${notebook.color}cc)`,
|
||||
}}
|
||||
@@ -1051,16 +1149,18 @@ function NotebookDetailView({
|
||||
onToggleBookmark,
|
||||
onTogglePublic,
|
||||
onReorderPages,
|
||||
onDuplicatePage,
|
||||
}: {
|
||||
notebook: Notebook;
|
||||
subjectName: string | null;
|
||||
onBack: () => void;
|
||||
onUpdatePage: (pageId: string, data: Partial<NotebookPage>) => Promise<void>;
|
||||
onAddPage: () => void;
|
||||
onAddPage: (templateKey?: string, background?: string, content?: string) => Promise<void>;
|
||||
onDeletePage: (pageId: string) => void;
|
||||
onToggleBookmark: (pageId: string) => void;
|
||||
onTogglePublic: () => void;
|
||||
onReorderPages: (pageOrders: Array<{ id: string; pageNumber: number }>) => Promise<void>;
|
||||
onDuplicatePage: (pageId: string) => Promise<void>;
|
||||
}) {
|
||||
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
||||
const [pageContent, setPageContent] = useState('');
|
||||
@@ -1084,6 +1184,27 @@ function NotebookDetailView({
|
||||
// Collaboration state
|
||||
const [collabActivityOpen, setCollabActivityOpen] = useState(false);
|
||||
|
||||
// Sticky notes state
|
||||
const [stickyNotes, setStickyNotes] = useState<StickyNoteData[]>([]);
|
||||
const [showStickyNoteDialog, setShowStickyNoteDialog] = useState(false);
|
||||
const [newStickyNoteColor, setNewStickyNoteColor] = useState('#fef08a');
|
||||
const [editingStickyNote, setEditingStickyNote] = useState<StickyNoteData | null>(null);
|
||||
|
||||
// Search within notebook state
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Array<{ pageId: string; pageTitle: string; snippet: string; pageNumber: number }>>([]);
|
||||
|
||||
// Page template chooser state
|
||||
const [templateChooserOpen, setTemplateChooserOpen] = useState(false);
|
||||
|
||||
// Sections state
|
||||
const [sections, setSections] = useState<SectionData[]>([]);
|
||||
const [showSectionDialog, setShowSectionDialog] = useState(false);
|
||||
const [newSectionName, setNewSectionName] = useState('');
|
||||
const [newSectionColor, setNewSectionColor] = useState('#10b981');
|
||||
const [showTableOfContents, setShowTableOfContents] = useState(false);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSavedContentRef = useRef<string>('');
|
||||
@@ -1450,6 +1571,94 @@ function NotebookDetailView({
|
||||
setDragOverPageId(null);
|
||||
}, [dragOverPageId, handleDrop]);
|
||||
|
||||
// ─── Sticky Note Handlers ──────────────────────────────────────────
|
||||
|
||||
const addStickyNote = useCallback(() => {
|
||||
if (!currentPage) return;
|
||||
const note: StickyNoteData = {
|
||||
id: `sticky_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
x: 10 + Math.random() * 30,
|
||||
y: 10 + Math.random() * 30,
|
||||
width: 180,
|
||||
height: 120,
|
||||
color: newStickyNoteColor,
|
||||
text: '',
|
||||
pageId: currentPage.id,
|
||||
};
|
||||
setStickyNotes(prev => [...prev, note]);
|
||||
setShowStickyNoteDialog(false);
|
||||
}, [currentPage, newStickyNoteColor]);
|
||||
|
||||
const updateStickyNote = useCallback((id: string, updates: Partial<StickyNoteData>) => {
|
||||
setStickyNotes(prev => prev.map(n => n.id === id ? { ...n, ...updates } : n));
|
||||
}, []);
|
||||
|
||||
const deleteStickyNote = useCallback((id: string) => {
|
||||
setStickyNotes(prev => prev.filter(n => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const currentPageStickyNotes = useMemo(
|
||||
() => stickyNotes.filter(n => n.pageId === currentPageId),
|
||||
[stickyNotes, currentPageId]
|
||||
);
|
||||
|
||||
// ─── Search Within Notebook ────────────────────────────────────────
|
||||
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
const results: Array<{ pageId: string; pageTitle: string; snippet: string; pageNumber: number }> = [];
|
||||
for (const page of pages) {
|
||||
const plainText = (page.textContent ?? '').replace(/<[^>]*>/g, '');
|
||||
const title = page.title ?? `${t('notebooks.page')} ${page.pageNumber}`;
|
||||
const combined = (title + ' ' + plainText).toLowerCase();
|
||||
if (combined.includes(q)) {
|
||||
const idx = combined.indexOf(q);
|
||||
const start = Math.max(0, idx - 30);
|
||||
const end = Math.min(plainText.length, idx + query.length + 30);
|
||||
const snippet = (start > 0 ? '...' : '') + plainText.substring(start, end) + (end < plainText.length ? '...' : '');
|
||||
results.push({ pageId: page.id, pageTitle: title, snippet, pageNumber: page.pageNumber });
|
||||
}
|
||||
}
|
||||
setSearchResults(results);
|
||||
}, [pages]);
|
||||
|
||||
// ─── Section Handlers ──────────────────────────────────────────────
|
||||
|
||||
const addSection = useCallback(() => {
|
||||
if (!newSectionName.trim()) return;
|
||||
const section: SectionData = {
|
||||
id: `section_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
name: newSectionName.trim(),
|
||||
color: newSectionColor,
|
||||
pageIds: [],
|
||||
};
|
||||
setSections(prev => [...prev, section]);
|
||||
setNewSectionName('');
|
||||
setShowSectionDialog(false);
|
||||
}, [newSectionName, newSectionColor]);
|
||||
|
||||
const deleteSection = useCallback((sectionId: string) => {
|
||||
setSections(prev => prev.filter(s => s.id !== sectionId));
|
||||
}, []);
|
||||
|
||||
const getSectionForPage = useCallback((pageId: string): SectionData | null => {
|
||||
return sections.find(s => s.pageIds.includes(pageId)) ?? null;
|
||||
}, [sections]);
|
||||
|
||||
// ─── Page Template Handler ─────────────────────────────────────────
|
||||
|
||||
const handleAddFromTemplate = useCallback(async (templateKey: string) => {
|
||||
const template = PAGE_TEMPLATES.find(t => t.key === templateKey);
|
||||
if (!template) return;
|
||||
setTemplateChooserOpen(false);
|
||||
await onAddPage(templateKey, template.background, template.getContent());
|
||||
}, [onAddPage]);
|
||||
|
||||
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
|
||||
|
||||
return (
|
||||
@@ -1626,6 +1835,219 @@ function NotebookDetailView({
|
||||
<Clock className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Search within notebook */}
|
||||
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.search_pages')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent align="end" className="w-80 p-0 rounded-xl">
|
||||
<div className="p-3">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder={t('notebooks.search_pages_placeholder')}
|
||||
className="min-h-[44px]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{searchQuery.trim() && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800">
|
||||
<ScrollArea className="max-h-60">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('notebooks.search_no_results')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{searchResults.length} {t('notebooks.search_results')}
|
||||
</div>
|
||||
{searchResults.map((result) => (
|
||||
<button
|
||||
key={result.pageId}
|
||||
onClick={() => { setCurrentPageId(result.pageId); setSearchOpen(false); }}
|
||||
className="w-full text-left p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[44px]"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{result.pageTitle}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{result.snippet}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Sticky note button */}
|
||||
<Popover open={showStickyNoteDialog} onOpenChange={setShowStickyNoteDialog}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
|
||||
<StickyNote className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.sticky_note_add')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent align="end" className="w-auto p-3 rounded-xl">
|
||||
<p className="text-sm font-medium mb-2">{t('notebooks.sticky_note_color')}</p>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{STICKY_NOTE_COLORS.map((sc) => (
|
||||
<button
|
||||
key={sc.key}
|
||||
onClick={() => setNewStickyNoteColor(sc.color)}
|
||||
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 ${
|
||||
newStickyNoteColor === sc.color ? 'border-gray-900 dark:border-white scale-110 shadow-md' : 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: sc.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={addStickyNote} className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700" size="sm">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('notebooks.sticky_note_add')}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Sections button */}
|
||||
<Popover open={showSectionDialog} onOpenChange={setShowSectionDialog}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.sections')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
|
||||
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<h3 className="text-sm font-semibold">{t('notebooks.sections')}</h3>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
<Input
|
||||
value={newSectionName}
|
||||
onChange={(e) => setNewSectionName(e.target.value)}
|
||||
placeholder={t('notebooks.section_name_placeholder')}
|
||||
className="min-h-[44px]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{SECTION_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewSectionColor(color)}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-all hover:scale-110 ${
|
||||
newSectionColor === color ? 'border-gray-900 dark:border-white scale-110' : 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={addSection}
|
||||
disabled={!newSectionName.trim()}
|
||||
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('notebooks.section_add')}
|
||||
</Button>
|
||||
</div>
|
||||
{sections.length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 p-2 space-y-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: section.color }} />
|
||||
<span className="flex-1 truncate text-gray-700 dark:text-gray-300">{section.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteSection(section.id)}
|
||||
className="h-7 w-7 p-0 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Table of Contents button */}
|
||||
<Popover open={showTableOfContents} onOpenChange={setShowTableOfContents}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
|
||||
<BookMarked className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">{t('notebooks.table_of_contents')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
|
||||
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<h3 className="text-sm font-semibold">{t('notebooks.table_of_contents')}</h3>
|
||||
</div>
|
||||
<ScrollArea className="max-h-60">
|
||||
{pages.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('notebooks.no_sections')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-0.5">
|
||||
{pages.map((page) => {
|
||||
const section = getSectionForPage(page.id);
|
||||
return (
|
||||
<div key={page.id}>
|
||||
{section && page.id === section.pageIds[0] && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 mt-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: section.color }} />
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">{section.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setCurrentPageId(page.id); setShowTableOfContents(false); }}
|
||||
className="w-full text-left px-3 py-1.5 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center gap-2 min-h-[36px]"
|
||||
>
|
||||
<span className="text-xs text-gray-400 font-mono w-5 shrink-0">{page.pageNumber}</span>
|
||||
<span className="truncate text-gray-700 dark:text-gray-300">{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}</span>
|
||||
{page.isBookmark && <Bookmark className="w-3 h-3 text-amber-500 fill-amber-500 shrink-0" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Public toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1641,7 +2063,7 @@ function NotebookDetailView({
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left sidebar: page navigation */}
|
||||
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col">
|
||||
<div className="p-3">
|
||||
<div className="p-3 space-y-2">
|
||||
<Button
|
||||
onClick={onAddPage}
|
||||
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
||||
@@ -1650,6 +2072,15 @@ function NotebookDetailView({
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('notebooks.add_page')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setTemplateChooserOpen(true)}
|
||||
variant="outline"
|
||||
className="w-full min-h-[44px]"
|
||||
size="sm"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4 mr-1" />
|
||||
{t('notebooks.page_template')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-280px)]">
|
||||
@@ -1657,65 +2088,101 @@ function NotebookDetailView({
|
||||
{pages.map((page, idx) => {
|
||||
const isDragged = draggedPageId === page.id;
|
||||
const isDragOver = dragOverPageId === page.id;
|
||||
const section = getSectionForPage(page.id);
|
||||
const isFirstInSection = section && section.pageIds[0] === 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
|
||||
? '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}
|
||||
>
|
||||
{/* 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); }}
|
||||
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
|
||||
<React.Fragment key={page.id}>
|
||||
{/* Section divider */}
|
||||
{section && isFirstInSection && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: section.color }} />
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 truncate flex-1">{section.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteSection(section.id)}
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-red-500 shrink-0"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
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); }}
|
||||
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" />
|
||||
)}
|
||||
{/* Duplicate page button */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDuplicatePage(page.id); }}
|
||||
className="h-6 w-6 flex items-center justify-center rounded text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 shrink-0 transition-colors"
|
||||
>
|
||||
<ClipboardCopy className="w-3 h-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">{t('notebooks.duplicate_page')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
</motion.div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1958,6 +2425,47 @@ function NotebookDetailView({
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Sticky Notes Overlay */}
|
||||
{currentPageStickyNotes.map((note) => (
|
||||
<motion.div
|
||||
key={note.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="absolute z-30 shadow-lg rounded-md overflow-hidden group"
|
||||
style={{
|
||||
left: `${note.x}%`,
|
||||
top: `${note.y}%`,
|
||||
width: `${note.width}px`,
|
||||
minHeight: `${note.height}px`,
|
||||
backgroundColor: note.color,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1 bg-black/5">
|
||||
<span className="text-xs font-medium text-gray-600">{t('notebooks.sticky_note')}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setEditingStickyNote(note)}
|
||||
className="h-5 w-5 flex items-center justify-center rounded hover:bg-black/10 text-gray-600"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteStickyNote(note.id)}
|
||||
className="h-5 w-5 flex items-center justify-center rounded hover:bg-black/10 text-gray-600"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
value={note.text}
|
||||
onChange={(e) => updateStickyNote(note.id, { text: e.target.value })}
|
||||
className="border-0 bg-transparent shadow-none resize-none text-sm min-h-[80px] p-2 focus-visible:ring-0"
|
||||
placeholder={t('notebooks.sticky_note_edit') + '...'}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2019,6 +2527,40 @@ function NotebookDetailView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Template Chooser Dialog */}
|
||||
<Dialog open={templateChooserOpen} onOpenChange={setTemplateChooserOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LayoutTemplate className="w-5 h-5 text-emerald-500" />
|
||||
{t('notebooks.page_template_choose')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('notebooks.page_template')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3 py-2">
|
||||
{PAGE_TEMPLATES.map((template) => {
|
||||
const TemplateIcon = template.icon;
|
||||
return (
|
||||
<button
|
||||
key={template.key}
|
||||
onClick={() => handleAddFromTemplate(template.key)}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all min-h-[100px] hover:border-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<TemplateIcon className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t(template.titleKey)}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 text-center">{t(template.descKey)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTemplateChooserOpen(false)} className="min-h-[44px]">
|
||||
{t('action.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Version History Dialog */}
|
||||
<VersionHistoryDialog
|
||||
open={versionHistoryOpen}
|
||||
@@ -3315,12 +3857,14 @@ export default function NotebooksView() {
|
||||
});
|
||||
}, [selectedNotebook]);
|
||||
|
||||
const handleAddPage = useCallback(async () => {
|
||||
const handleAddPage = useCallback(async (templateKey?: string, background?: string, content?: string) => {
|
||||
if (!selectedNotebook) return;
|
||||
const pagesCount = selectedNotebook.pages?.length ?? 0;
|
||||
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
|
||||
pageNumber: pagesCount + 1,
|
||||
background: selectedNotebook.notebookType,
|
||||
background: background ?? selectedNotebook.notebookType,
|
||||
title: templateKey && templateKey !== 'blank' ? t(`notebooks.page_template_${templateKey}`) : null,
|
||||
textContent: content ?? null,
|
||||
});
|
||||
setSelectedNotebook(prev => {
|
||||
if (!prev) return prev;
|
||||
@@ -3332,6 +3876,32 @@ export default function NotebooksView() {
|
||||
toast.success(t('notebooks.page_added'));
|
||||
}, [selectedNotebook]);
|
||||
|
||||
const handleDuplicatePage = useCallback(async (pageId: string) => {
|
||||
if (!selectedNotebook) return;
|
||||
const page = selectedNotebook.pages?.find(p => p.id === pageId);
|
||||
if (!page) return;
|
||||
const pagesCount = selectedNotebook.pages?.length ?? 0;
|
||||
try {
|
||||
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
|
||||
pageNumber: pagesCount + 1,
|
||||
background: page.background,
|
||||
title: (page.title ?? '') + ' ' + t('notebooks.copy_suffix'),
|
||||
textContent: page.textContent,
|
||||
drawingData: page.drawingData,
|
||||
});
|
||||
setSelectedNotebook(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
pages: [...(prev.pages ?? []), newPage],
|
||||
};
|
||||
});
|
||||
toast.success(t('notebooks.duplicate_page_success'));
|
||||
} catch {
|
||||
toast.error(t('notebooks.duplicate_page_error'));
|
||||
}
|
||||
}, [selectedNotebook]);
|
||||
|
||||
const handleDeletePage = useCallback(async (pageId: string) => {
|
||||
if (!selectedNotebook) return;
|
||||
await apiDelete(`/api/notebooks/${selectedNotebook.id}/pages/${pageId}`);
|
||||
@@ -3525,6 +4095,7 @@ export default function NotebooksView() {
|
||||
onToggleBookmark={handleToggleBookmark}
|
||||
onTogglePublic={handleTogglePublic}
|
||||
onReorderPages={handleReorderPages}
|
||||
onDuplicatePage={handleDuplicatePage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -4013,6 +4584,21 @@ export default function NotebooksView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button for new notebook */}
|
||||
{!selectedNotebook && !showArchived && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="fab"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
title={t('notebooks.create')}
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Eco footer bar */}
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border-t border-emerald-200 dark:border-emerald-800 px-4 sm:px-6 py-3">
|
||||
<div className="flex items-center gap-3 text-emerald-700 dark:text-emerald-300 text-sm">
|
||||
|
||||
@@ -69,10 +69,8 @@ function getMasteryIcon(level: number) {
|
||||
}
|
||||
|
||||
const masteryBadge = (level: number) => {
|
||||
// Use CSS class-based mastery badges for deeper polish
|
||||
if (level <= 2) return 'mastery-badge-low';
|
||||
if (level <= 4) return 'mastery-badge-medium';
|
||||
return 'mastery-badge-high';
|
||||
// Use CSS class-based mastery indicators for deeper polish
|
||||
return `mastery-indicator mastery-indicator-${level}`;
|
||||
};
|
||||
|
||||
const masteryBorderColor = (level: number) => {
|
||||
@@ -899,7 +897,7 @@ export default function ProgressEntriesView() {
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.005 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`card-hover-lift flex-1 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 transition-shadow duration-200 hover:shadow-lg ${
|
||||
className={`card-shadow-transition flex-1 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 transition-shadow duration-200 hover:shadow-lg ${
|
||||
isSelected && batchMode ? 'ring-2 ring-red-300/60 dark:ring-red-700/50 bg-red-50/30 dark:bg-red-900/10' : ''
|
||||
}`}
|
||||
style={{ borderLeftColor: catColor }}
|
||||
|
||||
@@ -1130,6 +1130,135 @@ function PrivacyTab({ currentUser }: { currentUser: { id: string; role: string;
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Consent Management Card ─────────────────────────────── */}
|
||||
<Card className="card-hover-lift 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">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
{t('dsgvo.consent_management')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('dsgvo.consent_management_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[
|
||||
{ key: 'data_processing', icon: Database, color: 'emerald' },
|
||||
{ key: 'communication', icon: Mail, color: 'teal' },
|
||||
{ key: 'analytics', icon: BarChart3, color: 'amber' },
|
||||
{ key: 'third_party', icon: Users, color: 'violet' },
|
||||
].map((consent) => {
|
||||
const Icon = consent.icon;
|
||||
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
|
||||
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/30 dark:border-emerald-900/20' },
|
||||
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/30 dark:border-teal-900/20' },
|
||||
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200/30 dark:border-amber-900/20' },
|
||||
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/30 dark:border-violet-900/20' },
|
||||
};
|
||||
const c = colorMap[consent.color] || colorMap.emerald;
|
||||
return (
|
||||
<div key={consent.key} className={`flex items-center justify-between p-4 rounded-xl ${c.bg} border ${c.border}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={`h-5 w-5 ${c.text}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t(`dsgvo.consent_${consent.key}`)}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t(`dsgvo.consent_${consent.key}_desc`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${c.bg} ${c.text} border ${c.border} text-xs`}>
|
||||
{t('dsgvo.consent_granted')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Data Retention Settings Card ─────────────────────────────── */}
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-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-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
</div>
|
||||
{t('dsgvo.retention_settings')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('dsgvo.retention_settings_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[
|
||||
{ key: 'grades', icon: GraduationCap, color: 'emerald', defaultYears: 3 },
|
||||
{ key: 'attendance', icon: CalendarCheck, color: 'teal', defaultYears: 2 },
|
||||
{ key: 'behavior', icon: Heart, color: 'violet', defaultYears: 1 },
|
||||
].map((retention) => {
|
||||
const Icon = retention.icon;
|
||||
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
|
||||
emerald: { bg: 'bg-emerald-50/50 dark:bg-emerald-900/10', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/30 dark:border-emerald-900/20' },
|
||||
teal: { bg: 'bg-teal-50/50 dark:bg-teal-900/10', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/30 dark:border-teal-900/20' },
|
||||
violet: { bg: 'bg-violet-50/50 dark:bg-violet-900/10', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/30 dark:border-violet-900/20' },
|
||||
};
|
||||
const c = colorMap[retention.color] || colorMap.emerald;
|
||||
return (
|
||||
<div key={retention.key} className={`flex items-center justify-between p-4 rounded-xl ${c.bg} border ${c.border}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={`h-5 w-5 ${c.text}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t(`dsgvo.retention_${retention.key}`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{retention.defaultYears}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('dsgvo.retention_years')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Cookie Consent Management Card ─────────────────────────────── */}
|
||||
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-rose-400 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">
|
||||
<Shield className="h-4 w-4" />
|
||||
</div>
|
||||
{t('dsgvo.cookie_settings')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('dsgvo.cookie_consent')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t('dsgvo.cookie_essential')}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('dsgvo.cookie_essential_desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-xs">
|
||||
{t('dsgvo.consent_granted')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t('dsgvo.cookie_analytics')}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('dsgvo.cookie_analytics_desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs">
|
||||
{t('dsgvo.consent_revoked')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Delete Account Confirmation Dialog ─────────────────────── */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={(open) => { if (!open) { setDeleteDialogOpen(false); setDeletePassword(''); } }}>
|
||||
<AlertDialogContent>
|
||||
|
||||
Reference in new issue
Block a user