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:
1 parent
8e7116e57d
commit
86e4b504e4
7 files changed
+338
-35
No files matched your search
@@ -30,9 +30,8 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.user?.role !== 'TEACHER' && session.user?.role !== 'SCHOOL_ADMIN' && session.user?.role !== 'SUPER_ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
const role = session.user?.role;
|
||||
const isTeacherOrAdmin = role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN';
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classGroupId = searchParams.get('classGroupId');
|
||||
@@ -62,6 +61,47 @@ export async function GET(request: Request) {
|
||||
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 };
|
||||
if (dateFrom || dateTo) {
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Attendance GET error:', error);
|
||||
|
||||
@@ -42,9 +42,7 @@ async function getCompetitions(request: Request): Promise<NextResponse> {
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
if (!isTeacherOrAdmin(session.user?.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
// All authenticated users can view competitions (students, parents, teachers, admins)
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const schoolIdParam = searchParams.get('schoolId');
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
Clock as ClockIcon,
|
||||
Library as LibraryIcon,
|
||||
Trophy,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -237,6 +238,8 @@ const studentNavSections: NavSection[] = [
|
||||
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
|
||||
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
|
||||
{ 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: 'parents', icon: Mail, labelKey: 'parent.my_children' },
|
||||
{ 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: 'competitions', icon: Trophy, labelKey: 'nav.competitions' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -588,9 +588,9 @@ export default function CompetitionsView() {
|
||||
// Register
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const isTeacher = currentUser?.role === 'teacher';
|
||||
const isStudent = currentUser?.role === 'student';
|
||||
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
const isTeacher = currentUser?.role === 'TEACHER';
|
||||
const isStudent = currentUser?.role === 'STUDENT';
|
||||
const canCreate = isAdmin || isTeacher;
|
||||
const schoolId = currentUser?.schoolId;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
|
||||
Strikethrough, Type, Highlighter, GripVertical,
|
||||
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
|
||||
Users as UsersIcon, Radio, MousePointer2,
|
||||
Users as UsersIcon, Radio, MousePointer2, Columns2,
|
||||
ChevronRight, XCircle, Trophy, Zap, Flame, Dumbbell, Heart, RotateCcw, ArrowRight, Play,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -161,9 +161,10 @@ const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
|
||||
pages: [
|
||||
{ title: 'Aufgaben', content: '# Aufgaben\n\n1. \n2. \n3. \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: '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',
|
||||
iconComponent: BookOpen,
|
||||
pages: [
|
||||
{ title: 'Aufsaetze', content: '# Aufsaetze\n\n' },
|
||||
{ title: 'Lesetagebuch', content: '# Lesetagebuch\n\n' },
|
||||
{ title: 'Grammatik', content: '# Grammatik\n\n' },
|
||||
{ title: 'Rechtschreibung', content: '# Rechtschreibung\n\n' },
|
||||
{ title: 'Kreatives Schreiben', content: '# Kreatives Schreiben\n\n' },
|
||||
{ title: 'Aufsaetze', content: '# Aufsaetze\n\n**Einleitung:**\n\n**Hauptteil:**\n\n**Schluss:**\n\n' },
|
||||
{ title: 'Lesetagebuch', content: '# Lesetagebuch\n\n| Datum | Buch | Seiten | Meine Meinung |\n|-------|------|--------|---------------|\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| Falsch | Richtig | Regel |\n|--------|---------|-------|\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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 ─────────────────────────────────────────
|
||||
@@ -1017,7 +1065,8 @@ function NotebookDetailView({
|
||||
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
||||
const [pageContent, setPageContent] = 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 [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
|
||||
@@ -1534,16 +1583,38 @@ function NotebookDetailView({
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Drawing mode toggle */}
|
||||
<Button
|
||||
variant={drawingMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setDrawingMode(!drawingMode)}
|
||||
className={`min-h-[44px] shrink-0 transition-all ${drawingMode ? 'bg-emerald-600 hover:bg-emerald-700 shadow-md shadow-emerald-500/20' : ''}`}
|
||||
>
|
||||
<PenLine className="w-4 h-4 mr-1" />
|
||||
{drawingMode ? t('notebooks.drawing_mode_exit') : t('notebooks.drawing_mode_toggle')}
|
||||
</Button>
|
||||
{/* 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
|
||||
variant={viewMode === 'text' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('text')}
|
||||
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')}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</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 */}
|
||||
<Button
|
||||
@@ -1627,7 +1698,7 @@ function NotebookDetailView({
|
||||
|
||||
{/* Page info below thumbnail */}
|
||||
<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 ${
|
||||
currentPageId === page.id
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
|
||||
@@ -1720,8 +1791,8 @@ function NotebookDetailView({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{drawingMode ? (
|
||||
/* Drawing mode - render DrawingCanvas */
|
||||
{viewMode === 'drawing' ? (
|
||||
/* Full drawing mode - render DrawingCanvas */
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
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')}
|
||||
initialDrawingData={currentPage.drawingData ?? undefined}
|
||||
onSave={handleDrawingSave}
|
||||
onExit={() => setDrawingMode(false)}
|
||||
onExit={() => setViewMode('text')}
|
||||
title={currentPage.title ?? `${t('notebooks.page')} ${currentPage.pageNumber}`}
|
||||
/>
|
||||
</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">
|
||||
{/* WYSIWYG toolbar */}
|
||||
<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">
|
||||
<span>{t('notebooks.page')} {currentPage.pageNumber} / {pages.length}</span>
|
||||
{/* 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">
|
||||
<ImageIcon className="w-3 h-3" />
|
||||
{t('notebooks.view_drawing')}
|
||||
@@ -1811,11 +1976,11 @@ function NotebookDetailView({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View drawing button if page has drawing data */}
|
||||
{currentPage.drawingData && !drawingMode && (
|
||||
{currentPage.drawingData && viewMode === 'text' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDrawingMode(true)}
|
||||
onClick={() => setViewMode('split')}
|
||||
className="min-h-[44px]"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 mr-1" />
|
||||
|
||||
@@ -1934,6 +1934,11 @@ const de: TranslationDict = {
|
||||
'notebooks.drawing_mode': 'Zeichnungsmodus',
|
||||
'notebooks.drawing_mode_toggle': 'Zeichnen',
|
||||
'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.back_to_library': 'Zurück zur Bibliothek',
|
||||
'notebooks.open_notebook': 'Heft öffnen',
|
||||
@@ -2077,6 +2082,12 @@ const de: TranslationDict = {
|
||||
'notebooks.template_art_desc': 'Blanko-Heft zum Zeichnen',
|
||||
'notebooks.template_music_desc': 'Musikheft zum Komponieren',
|
||||
'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.owner': 'Von',
|
||||
|
||||
@@ -5421,6 +5432,11 @@ const en: TranslationDict = {
|
||||
'notebooks.drawing_mode': 'Drawing Mode',
|
||||
'notebooks.drawing_mode_toggle': 'Draw',
|
||||
'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.back_to_library': 'Back to Library',
|
||||
'notebooks.open_notebook': 'Open Notebook',
|
||||
@@ -5564,6 +5580,12 @@ const en: TranslationDict = {
|
||||
'notebooks.template_art_desc': 'Blank notebook for drawing',
|
||||
'notebooks.template_music_desc': 'Music notebook for composition',
|
||||
'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.owner': 'By',
|
||||
|
||||
|
||||
+40
@@ -1605,3 +1605,43 @@ Unresolved Issues:
|
||||
- Email notification integration (SMTP) still pending
|
||||
- Advanced analytics dashboard refinements
|
||||
- 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
|
||||
Reference in new issue
Block a user