feat: Fix Forbidden 403 errors, add notebook split view (text+handwriting), German curriculum templates, role-based navigation

- Fix attendance API: students/parents can now view their own attendance records
- Fix competitions API: all authenticated users can view competitions
- Add notebook split view: text editor + drawing canvas side by side
- Add resizable divider for split view with mouse drag support
- Add 3 new German curriculum templates: Grundschule, Geschichte, Religion/Ethik
- Enhance existing templates with more detailed content
- Fix role comparisons in competitions-view (uppercase role names)
- Add Resources and Settings to student navigation
- Add Grading, Attendance, Competitions to parent navigation
- Add i18n keys for new features (DE + EN)
This commit is contained in:
Mustafa Cetin committed 2026-07-29 15:05:07 +00:00
1 parent 8e7116e57d
commit 86e4b504e4
7 files changed
+334 -31

No files matched your search

+75 -3
View File
@@ -30,9 +30,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
} }
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') { const role = session.user?.role;
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); const isTeacherOrAdmin = role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const classGroupId = searchParams.get('classGroupId'); const classGroupId = searchParams.get('classGroupId');
@@ -62,6 +61,47 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }
// Students can only view their own attendance records
if (role === 'STUDENT') {
// Find the student record matching this user (by name + school)
const studentRecord = await db.student.findFirst({
where: {
schoolId: session.user.schoolId ?? undefined,
firstName: session.user.firstName,
lastName: session.user.lastName,
deletedAt: null,
},
});
if (!studentRecord) {
// No student record found for this user - return empty
return NextResponse.json([]);
}
// Verify the student is enrolled in this class
const enrollment = await db.enrollment.findFirst({
where: { classGroupId, studentId: studentRecord.id, endDate: null },
});
if (!enrollment) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
// Parents can view attendance if they have a child in the class
if (role === 'PARENT') {
const childEnrollment = await db.enrollment.findFirst({
where: { classGroupId, endDate: null },
include: {
student: {
include: {
parentLinks: { where: { parentId: session.userId } },
},
},
},
});
if (!childEnrollment || childEnrollment.student.parentLinks.length === 0) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const where: Record<string, unknown> = { classGroupId }; const where: Record<string, unknown> = { classGroupId };
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
const dateFilter: Record<string, unknown> = {}; const dateFilter: Record<string, unknown> = {};
@@ -88,6 +128,38 @@ export async function GET(request: Request) {
}, },
}); });
// For students, filter records to only show their own
if (role === 'STUDENT') {
const studentRecord = await db.student.findFirst({
where: {
schoolId: session.user.schoolId ?? undefined,
firstName: session.user.firstName,
lastName: session.user.lastName,
deletedAt: null,
},
});
const studentId = studentRecord?.id ?? '';
const filteredSessions = sessions.map((s) => ({
...s,
records: s.records.filter((r) => r.studentId === studentId),
}));
return NextResponse.json(filteredSessions);
}
// For parents, filter records to only show their children's
if (role === 'PARENT') {
const childIds = await db.parentStudentLink.findMany({
where: { parentId: session.userId },
select: { studentId: true },
});
const childIdSet = new Set(childIds.map((c) => c.studentId));
const filteredSessions = sessions.map((s) => ({
...s,
records: s.records.filter((r) => childIdSet.has(r.studentId)),
}));
return NextResponse.json(filteredSessions);
}
return NextResponse.json(sessions); return NextResponse.json(sessions);
} catch (error) { } catch (error) {
console.error('Attendance GET error:', error); console.error('Attendance GET error:', error);
+1 -3
View File
@@ -42,9 +42,7 @@ async function getCompetitions(request: Request): Promise<NextResponse> {
try { try {
const session = await getSession(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
if (!isTeacherOrAdmin(session.user?.role)) { // All authenticated users can view competitions (students, parents, teachers, admins)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const schoolIdParam = searchParams.get('schoolId'); const schoolIdParam = searchParams.get('schoolId');
+6
View File
@@ -58,6 +58,7 @@ import {
Clock as ClockIcon, Clock as ClockIcon,
Library as LibraryIcon, Library as LibraryIcon,
Trophy, Trophy,
FolderOpen,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Sidebar, Sidebar,
@@ -237,6 +238,8 @@ const studentNavSections: NavSection[] = [
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' }, { key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' }, { key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
{ key: 'resources', icon: FolderOpen, labelKey: 'nav.resources' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
], ],
}, },
]; ];
@@ -250,7 +253,10 @@ const parentNavSections: NavSection[] = [
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'parents', icon: Mail, labelKey: 'parent.my_children' }, { key: 'parents', icon: Mail, labelKey: 'parent.my_children' },
{ key: 'parents', icon: MessageSquareText, labelKey: 'parent.messages' }, { key: 'parents', icon: MessageSquareText, labelKey: 'parent.messages' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' }, { key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
{ key: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
], ],
}, },
]; ];
+3 -3
View File
@@ -588,9 +588,9 @@ export default function CompetitionsView() {
// Register // Register
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
const isAdmin = currentUser?.role === 'admin'; const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const isTeacher = currentUser?.role === 'teacher'; const isTeacher = currentUser?.role === 'TEACHER';
const isStudent = currentUser?.role === 'student'; const isStudent = currentUser?.role === 'STUDENT';
const canCreate = isAdmin || isTeacher; const canCreate = isAdmin || isTeacher;
const schoolId = currentUser?.schoolId; const schoolId = currentUser?.schoolId;
+187 -22
View File
@@ -15,7 +15,7 @@ import {
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone, Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
Strikethrough, Type, Highlighter, GripVertical, Strikethrough, Type, Highlighter, GripVertical,
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon, Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
Users as UsersIcon, Radio, MousePointer2, Users as UsersIcon, Radio, MousePointer2, Columns2,
ChevronRight, XCircle, Trophy, Zap, Flame, Dumbbell, Heart, RotateCcw, ArrowRight, Play, ChevronRight, XCircle, Trophy, Zap, Flame, Dumbbell, Heart, RotateCcw, ArrowRight, Play,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -161,9 +161,10 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
pages: [ pages: [
{ title: 'Aufgaben', content: '# Aufgaben\n\n1. \n2. \n3. \n' }, { title: 'Aufgaben', content: '# Aufgaben\n\n1. \n2. \n3. \n' },
{ title: 'Rechnungen', content: '# Rechnungen\n\n' }, { title: 'Rechnungen', content: '# Rechnungen\n\n' },
{ title: 'Formeln', content: '# Formeln\n\n' }, { title: 'Formeln', content: '# Formeln\n\n| Name | Formel |\n|------|--------|\n| Flaeche Rechteck | A = a · b |\n| Umfang Rechteck | U = 2·(a+b) |\n| Flaeche Dreieck | A = ½ · g · h |\n' },
{ title: 'Geometrie', content: '# Geometrie\n\n' }, { title: 'Geometrie', content: '# Geometrie\n\n' },
{ title: 'Ergebnisse', content: '# Ergebnisse\n\n' }, { title: 'Ergebnisse', content: '# Ergebnisse\n\n' },
{ title: 'Kontrolle', content: '# Kontrolle\n\n| Aufgabe | Ergebnis | Richtig? |\n|---------|----------|----------|\n| 1 | | |\n| 2 | | |\n| 3 | | |\n' },
], ],
}, },
{ {
@@ -175,11 +176,12 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
icon: 'BookOpen', icon: 'BookOpen',
iconComponent: BookOpen, iconComponent: BookOpen,
pages: [ pages: [
{ title: 'Aufsaetze', content: '# Aufsaetze\n\n' }, { title: 'Aufsaetze', content: '# Aufsaetze\n\n**Einleitung:**\n\n**Hauptteil:**\n\n**Schluss:**\n\n' },
{ title: 'Lesetagebuch', content: '# Lesetagebuch\n\n' }, { title: 'Lesetagebuch', content: '# Lesetagebuch\n\n| Datum | Buch | Seiten | Meine Meinung |\n|-------|------|--------|---------------|\n| | | | |\n' },
{ title: 'Grammatik', content: '# Grammatik\n\n' }, { title: 'Grammatik', content: '# Grammatik\n\n**Wortart:** \n\n**Regel:** \n\n**Beispiele:**\n1. \n2. \n3. \n' },
{ title: 'Rechtschreibung', content: '# Rechtschreibung\n\n' }, { title: 'Rechtschreibung', content: '# Rechtschreibung\n\n| Falsch | Richtig | Regel |\n|--------|---------|-------|\n| | | |\n' },
{ title: 'Kreatives Schreiben', content: '# Kreatives Schreiben\n\n' }, { title: 'Kreatives Schreiben', content: '# Kreatives Schreiben\n\n**Freies Schreiben:**\n\n' },
{ title: 'Textanalyse', content: '# Textanalyse\n\n**Titel:** \n\n**Autor:** \n\n**Textsorte:** \n\n**Zusammenfassung:**\n\n**Sprachliche Mittel:**\n\n' },
], ],
}, },
{ {
@@ -246,6 +248,52 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
{ title: 'Fragen', content: '# Fragen\n\n' }, { title: 'Fragen', content: '# Fragen\n\n' },
], ],
}, },
{
key: 'grundschule',
titleKey: 'notebooks.template_grundschule',
descKey: 'notebooks.template_grundschule_desc',
notebookType: 'lined',
color: '#f97316',
icon: 'Book',
iconComponent: Book,
pages: [
{ title: 'Wortschatz', content: '# Wortschatz\n\n| Wort | Artikel | Plural |\n|------|---------|--------|\n| | | |\n' },
{ title: 'Schreiben lernen', content: '# Schreiben lernen\n\n' },
{ title: 'Rechnen', content: '# Rechnen\n\n1. \n2. \n3. \n\n**Malfolgen:**\n\n| 1er | 2er | 5er | 10er |\n|-----|-----|-----|------|\n| 1 | 2 | 5 | 10 |\n| 2 | 4 | 10 | 20 |\n| 3 | 6 | 15 | 30 |\n' },
{ title: 'Lesen', content: '# Lesen\n\n**Gelesenes Buch:**\n\n**Meine Meinung:**\n\n' },
{ title: 'Sachkunde', content: '# Sachkunde\n\n**Thema:**\n\n**Was ich weiss:**\n\n**Was ich gelernt habe:**\n\n' },
],
},
{
key: 'history',
titleKey: 'notebooks.template_history',
descKey: 'notebooks.template_history_desc',
notebookType: 'lined',
color: '#92400e',
icon: 'BookMarked',
iconComponent: BookMarked,
pages: [
{ title: 'Zeitstrahl', content: '# Zeitstrahl\n\n| Jahr | Ereignis |\n|------|----------|\n| | |\n' },
{ title: 'Quellenanalyse', content: '# Quellenanalyse\n\n**Quellenart:**\n\n**Entstehungszeit:**\n\n**Verfasser:**\n\n**Aussage:**\n\n**Historischer Kontext:**\n\n' },
{ title: 'Begriffe', content: '# Begriffe\n\n| Begriff | Definition |\n|---------|------------|\n| | |\n' },
{ title: 'Ursachen und Wirkungen', content: '# Ursachen und Wirkungen\n\n**Ursachen:**\n1. \n2. \n\n**Wirkungen:**\n1. \n2. \n' },
],
},
{
key: 'religion',
titleKey: 'notebooks.template_religion',
descKey: 'notebooks.template_religion_desc',
notebookType: 'lined',
color: '#f97316',
icon: 'Heart',
iconComponent: Heart,
pages: [
{ title: 'Bibelstellen', content: '# Bibelstellen\n\n| Stelle | Text | Bedeutung |\n|--------|------|----------|\n| | | |\n' },
{ title: 'Gedanken', content: '# Gedanken\n\n' },
{ title: 'Ethik', content: '# Ethik\n\n**Situation:**\n\n**Verschiedene Sichtweisen:**\n\n**Meine Meinung:**\n\n' },
{ title: 'Weltreligionen', content: '# Weltreligionen\n\n| Religion | Gruender | Heilige Schrift |\n|----------|----------|----------------|\n| Christentum | Jesus | Bibel |\n| Islam | Mohammed | Koran |\n| Judentum | Moses | Tora |\n| Buddhismus | Buddha | Tripitaka |\n| Hinduismus | - | Veden |\n' },
],
},
]; ];
// ─── CSS Background Patterns ───────────────────────────────────────── // ─── CSS Background Patterns ─────────────────────────────────────────
@@ -1017,7 +1065,8 @@ function NotebookDetailView({
const [currentPageId, setCurrentPageId] = useState<string | null>(null); const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [pageContent, setPageContent] = useState(''); const [pageContent, setPageContent] = useState('');
const [pageTitle, setPageTitle] = useState(''); const [pageTitle, setPageTitle] = useState('');
const [drawingMode, setDrawingMode] = useState(false); const [viewMode, setViewMode] = useState<'text' | 'split' | 'drawing'>('text');
const [splitRatio, setSplitRatio] = useState(50); // percentage for text side
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
@@ -1534,16 +1583,38 @@ function NotebookDetailView({
</Popover> </Popover>
)} )}
{/* Drawing mode toggle */} {/* View mode toggle — Text / Split / Drawing */}
<div className="flex items-center rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shrink-0">
<Button <Button
variant={drawingMode ? 'default' : 'outline'} variant={viewMode === 'text' ? 'default' : 'ghost'}
size="sm" size="sm"
onClick={() => setDrawingMode(!drawingMode)} onClick={() => setViewMode('text')}
className={`min-h-[44px] shrink-0 transition-all ${drawingMode ? 'bg-emerald-600 hover:bg-emerald-700 shadow-md shadow-emerald-500/20' : ''}`} className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'text' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
title={t('notebooks.view_text_only')}
> >
<PenLine className="w-4 h-4 mr-1" /> <Edit3 className="w-4 h-4" />
{drawingMode ? t('notebooks.drawing_mode_exit') : t('notebooks.drawing_mode_toggle')}
</Button> </Button>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700" />
<Button
variant={viewMode === 'split' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('split')}
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'split' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
title={t('notebooks.view_split')}
>
<Columns2 className="w-4 h-4" />
</Button>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700" />
<Button
variant={viewMode === 'drawing' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('drawing')}
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'drawing' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
title={t('notebooks.view_drawing_only')}
>
<PenLine className="w-4 h-4" />
</Button>
</div>
{/* Version history button */} {/* Version history button */}
<Button <Button
@@ -1627,7 +1698,7 @@ function NotebookDetailView({
{/* Page info below thumbnail */} {/* Page info below thumbnail */}
<button <button
onClick={() => { setCurrentPageId(page.id); setDrawingMode(false); }} 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 ${ 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 currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
@@ -1720,8 +1791,8 @@ function NotebookDetailView({
</Button> </Button>
</div> </div>
{drawingMode ? ( {viewMode === 'drawing' ? (
/* Drawing mode - render DrawingCanvas */ /* Full drawing mode - render DrawingCanvas */
<motion.div <motion.div
initial={{ opacity: 0, x: 30 }} initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
@@ -1733,12 +1804,106 @@ function NotebookDetailView({
backgroundType={currentPage.background === 'music' || currentPage.background === 'calligraphy' ? 'blank' : (currentPage.background as 'blank' | 'lined' | 'grid' | 'dotted') ?? (notebook.notebookType as 'blank' | 'lined' | 'grid' | 'dotted')} backgroundType={currentPage.background === 'music' || currentPage.background === 'calligraphy' ? 'blank' : (currentPage.background as 'blank' | 'lined' | 'grid' | 'dotted') ?? (notebook.notebookType as 'blank' | 'lined' | 'grid' | 'dotted')}
initialDrawingData={currentPage.drawingData ?? undefined} initialDrawingData={currentPage.drawingData ?? undefined}
onSave={handleDrawingSave} onSave={handleDrawingSave}
onExit={() => setDrawingMode(false)} onExit={() => setViewMode('text')}
title={currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`} title={currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`}
/> />
</motion.div> </motion.div>
) : viewMode === 'split' ? (
/* Split view - text editor on left, drawing canvas on right */
<div className="flex-1 flex overflow-hidden">
{/* Left: Text editor */}
<div className="flex flex-col overflow-hidden" style={{ width: `${splitRatio}%` }}>
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
<div className="flex-1 overflow-hidden p-3 relative">
<div
className="w-full h-full rounded-xl overflow-hidden"
style={{ backgroundColor: '#fff' }}
>
<ScrollArea className="h-full">
<div
className="min-h-full p-6"
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
onMouseMove={handleEditorMouseMove}
>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={handleEditorInput}
data-placeholder={t('notebooks.page_content') + '...'}
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
/>
</div>
</ScrollArea>
</div>
{cursors.filter(c => c.pageId === currentPage?.id).map((cursor) => (
<motion.div
key={cursor.userId}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute pointer-events-none z-20"
style={{
left: `${cursor.x}%`,
top: `${cursor.y}%`,
transform: 'translate(-4px, -4px)',
}}
>
<MousePointer2 className="w-4 h-4" style={{ color: cursor.color, fill: cursor.color }} />
<div className="px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap mt-0.5" style={{ backgroundColor: cursor.color }}>
{cursor.userName}
</div>
</motion.div>
))}
</div>
</div>
{/* Resizable divider */}
<div
className="w-2 cursor-col-resize bg-gray-200 dark:bg-gray-700 hover:bg-emerald-400 dark:hover:bg-emerald-600 transition-colors flex items-center justify-center shrink-0"
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startRatio = splitRatio;
const container = e.currentTarget.parentElement;
if (!container) return;
const containerWidth = container.getBoundingClientRect().width;
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const newRatio = Math.min(80, Math.max(20, startRatio + (delta / containerWidth) * 100));
setSplitRatio(newRatio);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}}
>
<div className="w-0.5 h-8 bg-gray-400 dark:bg-gray-500 rounded-full" />
</div>
{/* Right: Drawing canvas */}
<div className="flex flex-col overflow-hidden" style={{ width: `${100 - splitRatio}%` }}>
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<PenLine className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('notebooks.handwriting_panel')}</span>
<span className="text-xs text-gray-400 dark:text-gray-500 ml-auto">{t('notebooks.finger_stylus_hint')}</span>
</div>
<div className="flex-1 overflow-hidden">
<DrawingCanvas
backgroundType={currentPage.background === 'music' || currentPage.background === 'calligraphy' ? 'blank' : (currentPage.background as 'blank' | 'lined' | 'grid' | 'dotted') ?? (notebook.notebookType as 'blank' | 'lined' | 'grid' | 'dotted')}
initialDrawingData={currentPage.drawingData ?? undefined}
onSave={handleDrawingSave}
title={currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`}
/>
</div>
</div>
</div>
) : ( ) : (
/* Text editing area with WYSIWYG toolbar and page background */ /* Text-only mode with WYSIWYG toolbar and page background */
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* WYSIWYG toolbar */} {/* WYSIWYG toolbar */}
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} /> <WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
@@ -1802,7 +1967,7 @@ function NotebookDetailView({
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>{t('notebooks.page')} {currentPage.pageNumber} / {pages.length}</span> <span>{t('notebooks.page')} {currentPage.pageNumber} / {pages.length}</span>
{/* Drawing indicator when not in drawing mode */} {/* Drawing indicator when not in drawing mode */}
{currentPage.drawingData && !drawingMode && ( {currentPage.drawingData && viewMode === 'text' && (
<Badge variant="outline" className="text-xs gap-1 border-emerald-300 text-emerald-600 dark:border-emerald-700 dark:text-emerald-400"> <Badge variant="outline" className="text-xs gap-1 border-emerald-300 text-emerald-600 dark:border-emerald-700 dark:text-emerald-400">
<ImageIcon className="w-3 h-3" /> <ImageIcon className="w-3 h-3" />
{t('notebooks.view_drawing')} {t('notebooks.view_drawing')}
@@ -1811,11 +1976,11 @@ function NotebookDetailView({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View drawing button if page has drawing data */} {/* View drawing button if page has drawing data */}
{currentPage.drawingData && !drawingMode && ( {currentPage.drawingData && viewMode === 'text' && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setDrawingMode(true)} onClick={() => setViewMode('split')}
className="min-h-[44px]" className="min-h-[44px]"
> >
<ImageIcon className="w-4 h-4 mr-1" /> <ImageIcon className="w-4 h-4 mr-1" />
+22
View File
@@ -1934,6 +1934,11 @@ const de: TranslationDict = {
'notebooks.drawing_mode': 'Zeichnungsmodus', 'notebooks.drawing_mode': 'Zeichnungsmodus',
'notebooks.drawing_mode_toggle': 'Zeichnen', 'notebooks.drawing_mode_toggle': 'Zeichnen',
'notebooks.drawing_mode_exit': 'Zurück zum Text', 'notebooks.drawing_mode_exit': 'Zurück zum Text',
'notebooks.view_text_only': 'Nur Text',
'notebooks.view_split': 'Text + Handschrift',
'notebooks.view_drawing_only': 'Nur Zeichnung',
'notebooks.handwriting_panel': 'Handschrift-Panel',
'notebooks.finger_stylus_hint': 'Finger oder Stift',
'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert', 'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert',
'notebooks.back_to_library': 'Zurück zur Bibliothek', 'notebooks.back_to_library': 'Zurück zur Bibliothek',
'notebooks.open_notebook': 'Heft öffnen', 'notebooks.open_notebook': 'Heft öffnen',
@@ -2077,6 +2082,12 @@ const de: TranslationDict = {
'notebooks.template_art_desc': 'Blanko-Heft zum Zeichnen', 'notebooks.template_art_desc': 'Blanko-Heft zum Zeichnen',
'notebooks.template_music_desc': 'Musikheft zum Komponieren', 'notebooks.template_music_desc': 'Musikheft zum Komponieren',
'notebooks.template_science_desc': 'Kariertes Heft fuer Experimente', 'notebooks.template_science_desc': 'Kariertes Heft fuer Experimente',
'notebooks.template_grundschule': 'Grundschule',
'notebooks.template_grundschule_desc': 'Linertes Heft fuer die Grundschule',
'notebooks.template_history': 'Geschichte',
'notebooks.template_history_desc': 'Geschichtsheft mit Zeitstrahl und Quellenanalyse',
'notebooks.template_religion': 'Religion / Ethik',
'notebooks.template_religion_desc': 'Heft fuer Religion und Ethik',
'notebooks.tab_all': 'Meine Hefte', 'notebooks.tab_all': 'Meine Hefte',
'notebooks.owner': 'Von', 'notebooks.owner': 'Von',
@@ -5421,6 +5432,11 @@ const en: TranslationDict = {
'notebooks.drawing_mode': 'Drawing Mode', 'notebooks.drawing_mode': 'Drawing Mode',
'notebooks.drawing_mode_toggle': 'Draw', 'notebooks.drawing_mode_toggle': 'Draw',
'notebooks.drawing_mode_exit': 'Back to Text', 'notebooks.drawing_mode_exit': 'Back to Text',
'notebooks.view_text_only': 'Text Only',
'notebooks.view_split': 'Text + Handwriting',
'notebooks.view_drawing_only': 'Drawing Only',
'notebooks.handwriting_panel': 'Handwriting Panel',
'notebooks.finger_stylus_hint': 'Finger or stylus',
'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component', 'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component',
'notebooks.back_to_library': 'Back to Library', 'notebooks.back_to_library': 'Back to Library',
'notebooks.open_notebook': 'Open Notebook', 'notebooks.open_notebook': 'Open Notebook',
@@ -5564,6 +5580,12 @@ const en: TranslationDict = {
'notebooks.template_art_desc': 'Blank notebook for drawing', 'notebooks.template_art_desc': 'Blank notebook for drawing',
'notebooks.template_music_desc': 'Music notebook for composition', 'notebooks.template_music_desc': 'Music notebook for composition',
'notebooks.template_science_desc': 'Grid notebook for experiments', 'notebooks.template_science_desc': 'Grid notebook for experiments',
'notebooks.template_grundschule': 'Primary School',
'notebooks.template_grundschule_desc': 'Lined notebook for primary school',
'notebooks.template_history': 'History',
'notebooks.template_history_desc': 'History notebook with timeline and source analysis',
'notebooks.template_religion': 'Religion / Ethics',
'notebooks.template_religion_desc': 'Notebook for religion and ethics',
'notebooks.tab_all': 'My Notebooks', 'notebooks.tab_all': 'My Notebooks',
'notebooks.owner': 'By', 'notebooks.owner': 'By',
+40
View File
@@ -1605,3 +1605,43 @@ Unresolved Issues:
- Email notification integration (SMTP) still pending - Email notification integration (SMTP) still pending
- Advanced analytics dashboard refinements - Advanced analytics dashboard refinements
- Performance optimization for older tablets - Performance optimization for older tablets
---
Task ID: Round-20
Agent: Main Agent
Task: Fix Forbidden errors, enhance notebooks with split view, add German curriculum templates, fix role-based navigation
Date: 2025-07-29
Work Log:
- **Fixed Forbidden (403) errors** for student and parent roles:
- `src/app/api/attendance/route.ts` — Students can now view their own attendance records (filtered by student record matching user name + school). Parents can view their children's attendance.
- `src/app/api/competitions/route.ts` — All authenticated users can now view competitions (GET). Only teachers/admins can create (POST).
- Fixed Student/User model mismatch: attendance API now correctly maps User.id to Student.id via name+school lookup.
- **Enhanced notebooks with split view** (Text + Handwriting simultaneously):
- Replaced `drawingMode` boolean toggle with `viewMode` state: 'text' | 'split' | 'drawing'
- Added three view mode buttons: "Nur Text", "Text + Handschrift", "Nur Zeichnung"
- Split view shows text editor on left, drawing canvas on right with resizable divider
- Drawing canvas in split mode has "Handschrift-Panel" header with "Finger oder Stift" hint
- Resizable divider with mouse drag support (min 20%, max 80%)
- Added `Columns2` icon import from lucide-react
- **Added more German curriculum notebook templates**:
- Enhanced Math template with formula tables and control table
- Enhanced German template with essay structure (Einleitung/Hauptteil/Schluss), reading journal, grammar rules, spelling table, text analysis
- Added Grundschule (Primary School) template with vocabulary, writing, math, reading, Sachkunde pages
- Added Geschichte (History) template with timeline, source analysis, terms, causes/effects
- Added Religion/Ethik template with Bible references, ethics, world religions comparison
- **Fixed role-based navigation**:
- `src/components/competitions-view.tsx` — Fixed role comparisons from lowercase ('admin', 'teacher', 'student') to uppercase ('SCHOOL_ADMIN', 'TEACHER', 'STUDENT')
- `src/components/app-layout.tsx` — Added Resources and Settings to student navigation, added Grading, Attendance, and Competitions to parent navigation
- Added `FolderOpen` icon import
- **Added i18n keys** for new features:
- German: view_text_only, view_split, view_drawing_only, handwriting_panel, finger_stylus_hint, template_grundschule, template_history, template_religion
- English: same keys with English translations
Stage Summary:
- Forbidden 403 errors for students/parents are now fixed for attendance and competitions
- Notebooks now support split view (text + handwriting simultaneously) with resizable divider
- Added 3 new German curriculum templates (Grundschule, Geschichte, Religion/Ethik)
- Enhanced existing templates with more detailed content (math formulas, German essay structure, etc.)
- Role-based navigation properly shows different menus for teacher, student, parent, admin
- QA verified: student login works, student dashboard shows correct nav, attendance no longer 403, competitions no longer 403, split view works in notebooks