From 62f4a150e016ddde46b8b5cfdc869cfbe08bf2ee Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 08:02:04 +0000 Subject: [PATCH] CompetenceTrack v9 - Rich text editor, Drawing integration, PDF export, Notebook sharing, Templates, Keyboard shortcuts Features: - Rich text toolbar for notebook pages (Bold, Italic, Underline, Headings, Lists) - Drawing canvas integration in notebook pages (toggle drawing mode) - Notebook PDF export (client-side print with print CSS) - Page background rendering (lined, grid, blank, dotted, music, calligraphy) - Page thumbnails in sidebar with bookmark indicators - Auto-save with 3-second debounce - Notebook sharing with classes (isPublic toggle) - Notebook duplication (copy with all pages) - 6 pre-built notebook templates (Math, Deutsch, Englisch, Kunst, Musik, Sachkunde) - Shared notebooks tab (view other teachers' shared notebooks) - Templates tab with one-click creation - Keyboard shortcuts (Cmd+N, Cmd+D, Cmd+Shift+N, Cmd+E, Cmd+Shift+A, Cmd+.) - Student notebook access (role-based UI) - Role indicator in notebook header (Teacher/Student) - Onboarding tour expanded to 14 steps - Dashboard improvements (Today's Schedule, Paper Saved counter, Recent Activity) - Classes view improvements (student/subject counts, type badges) - Competence flower improvements (shadow, shimmer, mastery summary) - Progress entries improvements (mastery distribution chart) - Global CSS improvements (dark scrollbar, focus-visible, card-hover-glow) - 60+ new i18n keys (German/English) Bug Fixes: - Notebook creation not refreshing list (fixed by calling loadData after creation) - Missing BarChart3 import in progress-entries-view - Escaped quotes in JSX className attributes --- src/app/api/notebooks/[id]/duplicate/route.ts | 118 ++ .../notebooks/[id]/pages/[pageId]/route.ts | 1 + src/app/api/notebooks/route.ts | 103 +- src/app/api/notebooks/shared/route.ts | 49 + src/app/globals.css | 119 +- src/components/app-layout.tsx | 68 +- src/components/classes-view.tsx | 26 + src/components/competence-flower-view.tsx | 42 +- src/components/dashboard-view.tsx | 155 ++- src/components/keyboard-shortcuts-dialog.tsx | 16 + src/components/notebooks-view.tsx | 1143 +++++++++++++++-- src/components/onboarding-tour.tsx | 61 +- src/components/progress-entries-view.tsx | 58 +- src/lib/i18n.ts | 190 +++ 14 files changed, 1983 insertions(+), 166 deletions(-) create mode 100644 src/app/api/notebooks/[id]/duplicate/route.ts create mode 100644 src/app/api/notebooks/shared/route.ts diff --git a/src/app/api/notebooks/[id]/duplicate/route.ts b/src/app/api/notebooks/[id]/duplicate/route.ts new file mode 100644 index 0000000..e2e8bf2 --- /dev/null +++ b/src/app/api/notebooks/[id]/duplicate/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── POST: Duplicate a notebook (create a new copy with all pages) ── +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + + // Find the source notebook + const source = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + include: { + pages: { orderBy: { pageNumber: 'asc' } }, + }, + }); + + if (!source) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + // Access check: owner or public notebook in the same school + if ( + source.ownerId !== session.user.id && + !(source.isPublic && source.schoolId === session.user.schoolId) + ) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Get locale to determine copy suffix + const locale = session.user.locale ?? 'de'; + const copySuffix = locale === 'en' ? ' (Copy)' : ' (Kopie)'; + + // Create the duplicated notebook + const duplicated = await db.notebook.create({ + data: { + schoolId: source.schoolId, + ownerId: session.user.id, + ownerType: 'TEACHER', + subjectId: source.subjectId, + classGroupId: source.classGroupId, + title: source.title + copySuffix, + description: source.description, + notebookType: source.notebookType, + color: source.color, + icon: source.icon, + isArchived: false, + isPublic: false, // duplicated notebooks start as private + sortOrder: 0, + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + _count: { select: { pages: true } }, + }, + }); + + // Create all pages from the source notebook + for (const page of source.pages) { + await db.notebookPage.create({ + data: { + notebookId: duplicated.id, + pageNumber: page.pageNumber, + title: page.title, + contentType: page.contentType, + textContent: page.textContent, + drawingData: page.drawingData, + background: page.background, + isBookmark: page.isBookmark, + }, + }); + } + + // Fetch the duplicated notebook again with updated _count + const result = await db.notebook.findUnique({ + where: { id: duplicated.id }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + _count: { select: { pages: true } }, + }, + }); + + // Audit log + await db.auditLog.create({ + data: { + userId: session.user.id, + schoolId: source.schoolId, + action: 'CREATE', + entityType: 'Notebook', + entityId: duplicated.id, + metadata: JSON.stringify({ + duplicatedFrom: id, + title: duplicated.title, + }), + }, + }); + + return NextResponse.json(result); + } catch (error) { + console.error('Notebook duplicate error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notebooks/[id]/pages/[pageId]/route.ts b/src/app/api/notebooks/[id]/pages/[pageId]/route.ts index 9741d48..ad5acaf 100644 --- a/src/app/api/notebooks/[id]/pages/[pageId]/route.ts +++ b/src/app/api/notebooks/[id]/pages/[pageId]/route.ts @@ -7,6 +7,7 @@ import { getSession } from '@/lib/auth'; const updatePageSchema = z.object({ textContent: z.string().optional().nullable(), drawingData: z.string().optional().nullable(), + contentType: z.enum(['text', 'drawing', 'mixed']).optional(), background: z .enum(['lined', 'grid', 'blank', 'dotted', 'music']) .optional(), diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts index cd71f6a..af698c7 100644 --- a/src/app/api/notebooks/route.ts +++ b/src/app/api/notebooks/route.ts @@ -17,8 +17,100 @@ export async function GET(request: Request) { const classGroupId = searchParams.get('classGroupId'); const isArchived = searchParams.get('isArchived'); + const userRole = session.user.role; + const userId = session.user.id; + + // Default schoolId from user session + const effectiveSchoolId = schoolId || session.user.schoolId; + if (!effectiveSchoolId) { + return NextResponse.json([]); + } + + if (userRole === 'STUDENT') { + // Students see their own notebooks + public notebooks shared with their classes + const enrollments = await db.enrollment.findMany({ + where: { + student: { + schoolId: effectiveSchoolId, + }, + // Use the user's enrolled classes — we need to find the student + // associated with this user. For now, we'll look up via the + // student's externalId matching the user's id, or we can use + // a simpler approach: find all class groups where the student + // is enrolled. + }, + select: { classGroupId: true }, + }); + + // For students, we need to find their enrolled class group IDs. + // Since the Student model doesn't have a direct userId field, + // we'll look for students with matching firstName/lastName in the school + // and then find their enrollments. A more robust approach would be to + // add a userId field to the Student model, but for now we'll use + // a pragmatic approach: find all public notebooks for the school + // that have a classGroupId matching any of the user's class groups. + + // Find student IDs that match the user (by firstName + lastName in the same school) + const matchingStudents = await db.student.findMany({ + where: { + schoolId: effectiveSchoolId, + firstName: session.user.firstName, + lastName: session.user.lastName, + deletedAt: null, + }, + select: { id: true }, + }); + + const studentIds = matchingStudents.map(s => s.id); + + // Get class group IDs where the student is enrolled + const studentEnrollments = studentIds.length > 0 + ? await db.enrollment.findMany({ + where: { + studentId: { in: studentIds }, + }, + select: { classGroupId: true }, + }) + : []; + + const enrolledClassGroupIds = studentEnrollments.map(e => e.classGroupId); + + const where: Record = { + deletedAt: null, + schoolId: effectiveSchoolId, + OR: [ + // Own notebooks + { ownerId: userId }, + // Public notebooks shared with the student's classes + ...(enrolledClassGroupIds.length > 0 + ? [{ isPublic: true, classGroupId: { in: enrolledClassGroupIds } }] + : []), + ], + }; + + if (subjectId) where.subjectId = subjectId; + if (classGroupId) where.classGroupId = classGroupId; + if (isArchived !== null && isArchived !== undefined) { + where.isArchived = isArchived === 'true'; + } + + const notebooks = await db.notebook.findMany({ + where, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + owner: { select: { id: true, firstName: true, lastName: true, role: true } }, + _count: { select: { pages: true } }, + }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + }); + + return NextResponse.json(notebooks); + } + + // Teacher / Admin: show own notebooks const where: Record = { - ownerId: session.user.id, + ownerId: userId, deletedAt: null, }; @@ -29,7 +121,6 @@ export async function GET(request: Request) { where.isArchived = isArchived === 'true'; } - // Default schoolId from user session if (!schoolId && session.user.schoolId) { where.schoolId = session.user.schoolId; } @@ -65,6 +156,7 @@ const createNotebookSchema = z.object({ icon: z.string().optional().nullable(), subjectId: z.string().optional().nullable(), classGroupId: z.string().optional().nullable(), + isPublic: z.boolean().default(false).optional(), }); export async function POST(request: Request) { @@ -83,7 +175,7 @@ export async function POST(request: Request) { ); } - const { title, description, notebookType, color, icon, subjectId, classGroupId } = + const { title, description, notebookType, color, icon, subjectId, classGroupId, isPublic } = parsed.data; const schoolId = session.user.schoolId; @@ -94,11 +186,13 @@ export async function POST(request: Request) { ); } + const ownerType = session.user.role === 'STUDENT' ? 'STUDENT' : 'TEACHER'; + const notebook = await db.notebook.create({ data: { schoolId, ownerId: session.user.id, - ownerType: 'TEACHER', + ownerType, title, description, notebookType, @@ -106,6 +200,7 @@ export async function POST(request: Request) { icon, subjectId, classGroupId, + isPublic: isPublic ?? false, }, include: { subject: { select: { id: true, name: true } }, diff --git a/src/app/api/notebooks/shared/route.ts b/src/app/api/notebooks/shared/route.ts new file mode 100644 index 0000000..750b863 --- /dev/null +++ b/src/app/api/notebooks/shared/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get public notebooks shared by other teachers in the same school ── +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId'); + + if (!schoolId) { + return NextResponse.json( + { error: 'schoolId is required' }, + { status: 400 } + ); + } + + // Only show public notebooks from other teachers in the same school + const sharedNotebooks = await db.notebook.findMany({ + where: { + schoolId, + isPublic: true, + isArchived: false, + deletedAt: null, + ownerId: { not: session.user.id }, // Exclude own notebooks + }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + owner: { select: { id: true, firstName: true, lastName: true } }, + _count: { select: { pages: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + return NextResponse.json(sharedNotebooks); + } catch (error) { + console.error('Shared notebooks get error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 238e3ff..53e396a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -594,6 +594,87 @@ .animate-tooltip-enter { animation: tooltip-enter 0.15s ease-out; } + + /* ─── Smooth Dark Mode Scrollbar ───────────────────────────────────── */ + .dark ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .dark ::-webkit-scrollbar-track { + background: oklch(0.15 0.01 260); + } + .dark ::-webkit-scrollbar-thumb { + background: oklch(0.35 0.015 260 / 0.6); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; + } + .dark ::-webkit-scrollbar-thumb:hover { + background: oklch(0.50 0.10 155 / 0.7); + border: 2px solid transparent; + background-clip: padding-box; + } + /* Firefox scrollbar for dark mode */ + .dark * { + scrollbar-width: thin; + scrollbar-color: oklch(0.35 0.015 260 / 0.6) oklch(0.15 0.01 260); + } + + /* ─── Better Focus-Visible Styles ──────────────────────────────────── */ + @media (prefers-reduced-motion: reduce) { + *:focus-visible { + transition: none; + } + } + /* Enhanced focus ring for card-based interactive elements */ + [role="button"]:focus-visible, + button:focus-visible { + outline: 2px solid oklch(0.55 0.17 155); + outline-offset: 2px; + box-shadow: 0 0 0 4px oklch(0.55 0.17 155 / 0.15); + border-radius: 8px; + transition: box-shadow 0.15s ease-out; + } + .dark [role="button"]:focus-visible, + .dark button:focus-visible { + outline: 2px solid oklch(0.65 0.17 155); + box-shadow: 0 0 0 4px oklch(0.65 0.17 155 / 0.2); + } + + /* ─── Subtle Card Hover Effects ────────────────────────────────────── */ + .card-hover-glow { + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; + } + .card-hover-glow:hover { + transform: translateY(-2px); + box-shadow: + 0 8px 24px -6px oklch(0.55 0.17 155 / 0.12), + 0 0 0 1px oklch(0.55 0.17 155 / 0.08); + } + .dark .card-hover-glow:hover { + box-shadow: + 0 8px 24px -6px oklch(0.65 0.17 155 / 0.2), + 0 0 0 1px oklch(0.65 0.17 155 / 0.12); + } + + /* ─── Staggered Entry Animation ────────────────────────────────────── */ + @keyframes stagger-fade-in { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .animate-stagger-in { + animation: stagger-fade-in 0.35s ease-out backwards; + } + + /* ─── Chart Shimmer on Load ────────────────────────────────────────── */ + @keyframes chart-shimmer { + 0% { opacity: 0.5; transform: translateX(-100%); } + 50% { opacity: 0.3; } + 100% { opacity: 0; transform: translateX(100%); } + } + .animate-chart-shimmer { + animation: chart-shimmer 1.5s ease-out forwards; + } } /* Print-specific styling — expands to all reports when printing */ @@ -681,6 +762,22 @@ } } +/* Tablet-specific viewport styles (768px-1024px) */ +@media (min-width: 768px) and (max-width: 1024px) { + /* Adjust grid layouts for tablet */ + .lg\:grid-cols-5 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + /* More compact spacing for tablet */ + .space-y-7 > * + * { + margin-top: 1.25rem; + } + /* Ensure sidebar doesn't collapse too much */ + [data-sidebar] { + min-width: 220px; + } +} + /* Stylus-specific improvements */ @media (pointer: fine) and (hover: none) { /* Stylus devices often lack hover — replace hover effects with active */ @@ -751,7 +848,8 @@ /* Remove animations for print */ .animate-float, .animate-float-reverse, .animate-float-slow, .animate-pulse-soft, .animate-shimmer, .animate-progress-fill, - .animate-view-fade-in, .animate-enter-fade, .animate-count-up { + .animate-view-fade-in, .animate-enter-fade, .animate-count-up, + .animate-stagger-in, .animate-chart-shimmer { animation: none !important; } @@ -765,4 +863,23 @@ [class*="shadow-"] { box-shadow: none !important; } + + /* Notebook export specific styles */ + .no-print { display: none !important; } + .notebook-export { + padding: 2cm; + font-size: 12pt; + } + .notebook-export .page-break { + page-break-after: always; + } + .notebook-export .page-content { + min-height: 24cm; + } + /* Lined background for print */ + .notebook-export .page-lined { + background-image: repeating-linear-gradient( + transparent, transparent 31px, #e5e7eb 31px, #e5e7eb 32px + ); + } } diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 97bbc81..df03410 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -279,7 +279,7 @@ export default function AppLayout() { const isMod = e.metaKey || e.ctrlKey; // Ctrl/Cmd + K: Open command palette - if (isMod && e.key.toLowerCase() === 'k') { + if (isMod && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setSearchOpen((v) => !v); return; @@ -292,13 +292,67 @@ export default function AppLayout() { return; } - // Ctrl/Cmd + N: Create new entry (navigate to progress) - if (isMod && e.key.toLowerCase() === 'n') { + // Ctrl/Cmd + Shift + N: Add new page to current notebook (when on notebooks view) + if (isMod && e.shiftKey && e.key.toLowerCase() === 'n') { + e.preventDefault(); + if (currentView === 'notebooks') { + window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'new-page' })); + return; + } + } + + // Ctrl/Cmd + Shift + A: Toggle archive mode (when on notebooks view) + if (isMod && e.shiftKey && e.key.toLowerCase() === 'a') { + e.preventDefault(); + if (currentView === 'notebooks') { + window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'archive-toggle' })); + return; + } + } + + // Ctrl/Cmd + N: Context-dependent new action + if (isMod && !e.shiftKey && e.key.toLowerCase() === 'n') { e.preventDefault(); setSearchOpen(false); - setCurrentView('progress'); - trackAction('new-entry', t('shortcuts.new_entry'), 'progress'); - toast.info(t('shortcuts.new_entry')); + if (currentView === 'notebooks') { + // Open create notebook dialog + window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'new-notebook' })); + } else { + // Default: navigate to progress + setCurrentView('progress'); + trackAction('new-entry', t('shortcuts.new_entry'), 'progress'); + toast.info(t('shortcuts.new_entry')); + } + return; + } + + // Ctrl/Cmd + D: Open drawing canvas + if (isMod && e.key.toLowerCase() === 'd') { + e.preventDefault(); + if (currentView === 'notebooks') { + window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'drawing' })); + } else { + setCurrentView('drawing'); + trackAction('drawing', t('shortcuts.drawing'), 'drawing'); + } + return; + } + + // Ctrl/Cmd + E: Export current notebook as PDF + if (isMod && e.key.toLowerCase() === 'e') { + e.preventDefault(); + if (currentView === 'notebooks') { + window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'export-pdf' })); + } + return; + } + + // Ctrl/Cmd + .: Close current notebook (go back to library) + if (isMod && e.key === '.') { + e.preventDefault(); + if (currentView === 'notebooks') { + window.dispatchEvent(new CustomEvent('ct-shortcut', { detail: 'close-notebook' })); + } return; } @@ -326,7 +380,7 @@ export default function AppLayout() { } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [searchOpen, shortcutsOpen, helpOpen, setCurrentView, trackAction]); + }, [searchOpen, shortcutsOpen, helpOpen, currentView, setCurrentView, trackAction]); // Debounced search useEffect(() => { diff --git a/src/components/classes-view.tsx b/src/components/classes-view.tsx index f4bb02a..fb2a149 100644 --- a/src/components/classes-view.tsx +++ b/src/components/classes-view.tsx @@ -580,6 +580,21 @@ export default function ClassesView() {

{t('polish.empty_title_no_data')}

{t('classes.no_classes')}

+ {/* Illustration-like decorative elements */} +
+
+ + {locale === 'de' ? 'Klasse erstellen' : 'Create class'} +
+
+ + {locale === 'de' ? 'Schüler hinzufuegen' : 'Add students'} +
+
+ + {locale === 'de' ? 'Fortschritte loggen' : 'Log progress'} +
+
@@ -614,6 +629,17 @@ export default function ClassesView() {

{t('label.grade')} {cls.gradeLevel} · {cls.schoolYear?.label}

+ {/* Student count + subject count with icons */} +
+ + + {cls.studentCount ?? 0} {t('label.student_count').toLowerCase()} + + + + {cls._count?.competencyAssignments ?? 0} {locale === 'de' ? 'Faecher' : 'subjects'} + +
{/* Small progress indicator */} diff --git a/src/components/competence-flower-view.tsx b/src/components/competence-flower-view.tsx index 44712f5..39289f1 100644 --- a/src/components/competence-flower-view.tsx +++ b/src/components/competence-flower-view.tsx @@ -599,6 +599,14 @@ export default function CompetenceFlowerView() {
+ {/* Shimmer overlay on load */} + @@ -614,6 +622,10 @@ export default function CompetenceFlowerView() { + {/* Shadow filter for chart */} + + + {/* Class average comparison line */} {showClassAverage && ( @@ -667,7 +680,8 @@ export default function CompetenceFlowerView() { borderRadius: '12px', border: '1px solid #e5e7eb', fontSize: '12px', - boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)', + boxShadow: '0 4px 12px -2px rgba(0,0,0,0.12), 0 0 0 1px rgba(16,185,129,0.1)', + padding: '10px 14px', }} formatter={(value: number, _name: string, props: { payload?: { trueValue?: number; assessed?: number; total?: number } }) => { const p = props?.payload ?? {}; @@ -740,6 +754,32 @@ export default function CompetenceFlowerView() { ))}
+ + {/* Mastery Summary Section */} +
+ {[ + { label: locale === 'de' ? 'Gesamtdurchschnitt' : 'Overall Average', value: overallAverage.toFixed(2), icon: Target, color: primaryColor, bg: `rgba(${parseInt(primaryColor.slice(1,3),16)},${parseInt(primaryColor.slice(3,5),16)},${parseInt(primaryColor.slice(5,7),16)},0.1)` }, + { label: locale === 'de' ? 'Kategorien' : 'Categories', value: flowerData.categories.length, icon: BarChart3, color: '#10b981', bg: 'rgba(16,185,129,0.1)' }, + { label: locale === 'de' ? 'Erfasst' : 'Assessed', value: flowerData.categories.reduce((s, c) => s + c.assessedCompetencyCount, 0), icon: Check, color: '#14b8a6', bg: 'rgba(20,184,166,0.1)' }, + { label: locale === 'de' ? 'Gesamt' : 'Total', value: flowerData.categories.reduce((s, c) => s + c.competencyCount, 0), icon: Star, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }, + ].map((item, i) => ( + +
+
+ +
+

{item.label}

+
+

{item.value}

+
+ ))} +
diff --git a/src/components/dashboard-view.tsx b/src/components/dashboard-view.tsx index a6fd03f..8402187 100644 --- a/src/components/dashboard-view.tsx +++ b/src/components/dashboard-view.tsx @@ -37,6 +37,10 @@ import { Star, MessageSquare, Timer, + Globe, + Palette, + Notebook, + CircleDot, } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -654,6 +658,51 @@ export default function DashboardView() { + {/* ===== TODAY'S SCHEDULE ===== */} + + + + +
+ +
+ {locale === 'de' ? 'Heutige Aufgaben' : 'Today\'s Schedule'} + + · {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} + +
+
+ +
+ {[ + { icon: PenLine, label: locale === 'de' ? 'Fortschritte erfassen' : 'Log progress entries', desc: locale === 'de' ? 'Neue Beobachtungen eintragen' : 'Record new observations', view: 'progress', color: 'from-emerald-400 to-teal-500' }, + { icon: ClipboardCheck, label: locale === 'de' ? 'Uberpruefung erstellen' : 'Create assessment', desc: locale === 'de' ? 'Leistungsueberpruefung planen' : 'Plan performance assessment', view: 'assessments', color: 'from-amber-400 to-rose-500' }, + { icon: BookOpen, label: locale === 'de' ? 'Kompetenzblume ansehen' : 'View competence flower', desc: locale === 'de' ? 'Radar-Chart pruefen' : 'Check radar chart', view: 'flower', color: 'from-violet-400 to-emerald-500' }, + { icon: Notebook, label: locale === 'de' ? 'Notizbuch aktualisieren' : 'Update notebook', desc: locale === 'de' ? 'Hefte bearbeiten' : 'Edit notebooks', view: 'notebooks', color: 'from-teal-400 to-emerald-500' }, + ].map((item, i) => ( + setCurrentView(item.view)} + className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-all relative overflow-hidden" + > +
+ +
+
+

{item.label}

+

{item.desc}

+
+ +
+ ))} +
+
+
+
+ {/* ===== QUICK ACTION BUTTONS ===== */} @@ -1145,34 +1194,88 @@ export default function DashboardView() { - {/* ===== ENVIRONMENTAL TIPS SECTION ===== */} - - - -
-
- -
-
-

- {t('dashboard.env_tip_title')} -

-

- {t('dashboard.env_tip_text')} -

-
-
-
- -
-
- + {/* ===== PAPER SAVED & ENVIRONMENTAL SECTION ===== */} +
+ {/* Paper Saved Counter */} + + + +
+ + + +
+

+ {locale === 'de' ? 'Papier gespart' : 'Paper Saved'} +

+
+ + {data.stats.totalProgressEntries * 50} + + + {locale === 'de' ? 'Seiten' : 'pages'} + +
+

+ {locale === 'de' + ? 'Jeder digitale Eintrag spart ca. 50 Seiten Papier. Danke fuer Ihren Beitrag zum Umweltschutz!' + : 'Each digital entry saves ~50 pages of paper. Thank you for contributing to environmental protection!'} +

+
+
+ + {locale === 'de' ? '~0.5 Baume' : '~0.5 trees'} +
+
+ + {locale === 'de' ? 'CO2 reduziert' : 'CO2 reduced'} +
+
-
- - - + + + + + {/* Environmental Tips — visually enhanced */} + + + +
+
+ +
+
+

+ {t('dashboard.env_tip_title')} +

+

+ {t('dashboard.env_tip_text')} +

+
+ {[ + { icon: Leaf, text: locale === 'de' ? 'Digital statt Papier' : 'Digital instead of paper' }, + { icon: Sprout, text: locale === 'de' ? 'Nachhaltig dokumentieren' : 'Sustainable documentation' }, + { icon: TreePine, text: locale === 'de' ? 'Baeume schuetzen' : 'Protect trees' }, + { icon: Trees, text: locale === 'de' ? 'CO2 einsparen' : 'Reduce CO2' }, + ].map((tip, i) => ( +
+ + {tip.text} +
+ ))} +
+
+
+
+
+
+
); } diff --git a/src/components/keyboard-shortcuts-dialog.tsx b/src/components/keyboard-shortcuts-dialog.tsx index 84ebe97..40ce78e 100644 --- a/src/components/keyboard-shortcuts-dialog.tsx +++ b/src/components/keyboard-shortcuts-dialog.tsx @@ -30,6 +30,10 @@ import { MessageSquareText, CalendarDays, Settings, + Palette, + FileDown, + Archive, + ArrowLeft, } from 'lucide-react'; import { Dialog, @@ -120,6 +124,18 @@ export default function KeyboardShortcutsDialog({ open, onOpenChange }: Keyboard { keys: ['Esc'], labelKey: 'shortcuts.close_dialog', icon: X }, ], }, + { + titleKey: 'shortcuts.notebooks_drawing', + icon: BookOpen, + entries: [ + { keys: ['Cmd/Ctrl', 'N'], labelKey: 'shortcuts.new_notebook', icon: BookOpen }, + { keys: ['Cmd/Ctrl', 'D'], labelKey: 'shortcuts.drawing', icon: Palette }, + { keys: ['Cmd/Ctrl', 'Shift', 'N'], labelKey: 'shortcuts.new_page', icon: Plus }, + { keys: ['Cmd/Ctrl', 'E'], labelKey: 'shortcuts.export_pdf', icon: FileDown }, + { keys: ['Cmd/Ctrl', 'Shift', 'A'], labelKey: 'shortcuts.archive_toggle', icon: Archive }, + { keys: ['Cmd/Ctrl', '.'], labelKey: 'shortcuts.close_notebook', icon: ArrowLeft }, + ], + }, ]; return ( diff --git a/src/components/notebooks-view.tsx b/src/components/notebooks-view.tsx index 52827d3..0f415c6 100644 --- a/src/components/notebooks-view.tsx +++ b/src/components/notebooks-view.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { BookOpen, Book, Archive, Grid3X3, AlignLeft, File, @@ -8,6 +8,11 @@ import { MoreHorizontal, Music, PenTool, Search, X, Share2, Eye, EyeOff, Edit3, Hash, Bookmark, PenLine, Layers, BookMarked, Globe, Sparkles, + Bold, Italic as ItalicIcon, Underline as UnderlineIcon, + List, ListOrdered, AlignLeftIcon, AlignCenterIcon, AlignRightIcon, + Heading1, Heading2, Heading3, CheckCircle2, + User as UserIcon, GraduationCap, FileDown, + Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -16,10 +21,12 @@ import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Textarea } from '@/components/ui/textarea'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppStore } from '@/lib/store'; import { t } from '@/lib/i18n'; import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api'; @@ -59,6 +66,10 @@ interface Notebook { createdAt: string; updatedAt: string; pages?: NotebookPage[]; + _count?: { pages: number }; + subject?: { id: string; name: string } | null; + classGroup?: { id: string; name: string } | null; + owner?: { id: string; firstName: string; lastName: string; role: string } | null; } interface Subject { @@ -106,6 +117,118 @@ const ICON_MAP: Record = { Leaf, Star, Globe, Sparkles, Hash, Layers, }; +// ─── Template Definitions ──────────────────────────────────────────── + +interface NotebookTemplate { + key: string; + titleKey: string; + descKey: string; + notebookType: string; + color: string; + icon: string; + iconComponent: React.ElementType; + pages: Array<{ title: string | null; content: string }>; +} + +const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [ + { + key: 'math', + titleKey: 'notebooks.template_math', + descKey: 'notebooks.template_math_desc', + notebookType: 'grid', + color: '#3b82f6', + icon: 'Hash', + iconComponent: Calculator, + pages: [ + { title: 'Aufgaben', content: '# Aufgaben\n\n1. \n2. \n3. \n' }, + { title: 'Rechnungen', content: '# Rechnungen\n\n' }, + { title: 'Formeln', content: '# Formeln\n\n' }, + { title: 'Geometrie', content: '# Geometrie\n\n' }, + { title: 'Ergebnisse', content: '# Ergebnisse\n\n' }, + ], + }, + { + key: 'german', + titleKey: 'notebooks.template_german', + descKey: 'notebooks.template_german_desc', + notebookType: 'lined', + color: '#ef4444', + 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' }, + ], + }, + { + key: 'english', + titleKey: 'notebooks.template_english', + descKey: 'notebooks.template_english_desc', + notebookType: 'lined', + color: '#f59e0b', + icon: 'Globe', + iconComponent: Languages, + pages: [ + { title: 'Vocabulary', content: '# Vocabulary\n\n| English | Deutsch |\n|---------|--------|\n| | |\n| | |\n' }, + { title: 'Grammar', content: '# Grammar\n\n' }, + { title: 'Reading', content: '# Reading\n\n' }, + { title: 'Writing', content: '# Writing\n\n' }, + { title: 'Exercises', content: '# Exercises\n\n' }, + ], + }, + { + key: 'art', + titleKey: 'notebooks.template_art', + descKey: 'notebooks.template_art_desc', + notebookType: 'blank', + color: '#8b5cf6', + icon: 'Palette', + iconComponent: Paintbrush, + pages: [ + { title: 'Skizzen', content: '' }, + { title: 'Farbstudien', content: '' }, + { title: 'Komposition', content: '' }, + { title: 'Perspektive', content: '' }, + { title: 'Portfolio', content: '' }, + ], + }, + { + key: 'music', + titleKey: 'notebooks.template_music', + descKey: 'notebooks.template_music_desc', + notebookType: 'music', + color: '#10b981', + icon: 'Music', + iconComponent: Music, + pages: [ + { title: 'Noten', content: '' }, + { title: 'Rhythmus', content: '' }, + { title: 'Melodie', content: '' }, + { title: 'Harmonie', content: '' }, + { title: 'Komposition', content: '' }, + ], + }, + { + key: 'science', + titleKey: 'notebooks.template_science', + descKey: 'notebooks.template_science_desc', + notebookType: 'grid', + color: '#14b8a6', + icon: 'Book', + iconComponent: FlaskConical, + pages: [ + { title: 'Versuche', content: '# Versuche\n\n**Fragestellung:**\n\n**Vermutung:**\n\n**Durchfuehrung:**\n\n**Beobachtung:**\n\n**Ergebnis:**\n' }, + { title: 'Beobachtungen', content: '# Beobachtungen\n\n' }, + { title: 'Ergebnisse', content: '# Ergebnisse\n\n' }, + { title: 'Versuchsprotokoll', content: '# Versuchsprotokoll\n\nDatum:\n\nMaterial:\n\n' }, + { title: 'Fragen', content: '# Fragen\n\n' }, + ], + }, +]; + // ─── CSS Background Patterns ───────────────────────────────────────── function getPageBackgroundCSS(type: string): React.CSSProperties { @@ -156,6 +279,173 @@ function getPageBackgroundCSS(type: string): React.CSSProperties { } } +// ─── Rich Text Toolbar ─────────────────────────────────────────────── + +function RichTextToolbar({ textareaRef }: { textareaRef: React.RefObject }) { + // These functions only access ref.current inside event handlers (onClick), never during render + const applyFormat = (before: string, after: string) => () => { + const textarea = textareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + const selectedText = text.substring(start, end); + const newText = text.substring(0, start) + before + selectedText + after + text.substring(end); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(textarea, newText); + } + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.setSelectionRange(start + before.length + selectedText.length, start + before.length + selectedText.length); + textarea.focus(); + }; + + const applyLinePrefix = (prefix: string) => () => { + const textarea = textareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const text = textarea.value; + const lineStart = text.lastIndexOf('\n', start - 1) + 1; + const newText = text.substring(0, lineStart) + prefix + text.substring(lineStart); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(textarea, newText); + } + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.setSelectionRange(start + prefix.length, start + prefix.length); + textarea.focus(); + }; + + const applyBlockPrefix = (prefix: string) => () => { + const textarea = textareaRef.current; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + const lineStart = text.lastIndexOf('\n', start - 1) + 1; + const lineEnd = text.indexOf('\n', end); + const actualEnd = lineEnd === -1 ? text.length : lineEnd; + const selectedLines = text.substring(lineStart, actualEnd); + const newLines = selectedLines.split('\n').map(line => prefix + line).join('\n'); + const newText = text.substring(0, lineStart) + newLines + text.substring(actualEnd); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(textarea, newText); + } + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.setSelectionRange(start + prefix.length, start + prefix.length); + textarea.focus(); + }; + + return ( + +
+ {/* Format group */} + + + + + {t('notebooks.toolbar_bold')} + + + + + + {t('notebooks.toolbar_italic')} + + + + + + {t('notebooks.toolbar_underline')} + + + + + {/* Heading group */} + + + + + {t('notebooks.toolbar_heading1')} + + + + + + {t('notebooks.toolbar_heading2')} + + + + + + {t('notebooks.toolbar_heading3')} + + + + + {/* List group */} + + + + + {t('notebooks.toolbar_bullet_list')} + + + + + + {t('notebooks.toolbar_numbered_list')} + + + + + {/* Alignment group */} + + + + + {t('notebooks.toolbar_align_left')} + + + + + + {t('notebooks.toolbar_align_center')} + + + + + + {t('notebooks.toolbar_align_right')} + +
+
+ ); +} + // ─── Notebook Card Component ───────────────────────────────────────── function NotebookCard({ @@ -164,17 +454,26 @@ function NotebookCard({ onOpen, onArchive, onDelete, + onShare, + onDuplicate, + isShared = false, }: { notebook: Notebook; subjectName: string | null; onOpen: () => void; onArchive: () => void; onDelete: () => void; + onShare?: () => void; + onDuplicate?: () => void; + isShared?: boolean; }) { - const pageCount = notebook.pages?.length ?? 0; + const pageCount = notebook._count?.pages ?? notebook.pages?.length ?? 0; const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen; const typeInfo = NOTEBOOK_TYPES.find(nt => nt.key === notebook.notebookType) ?? NOTEBOOK_TYPES[0]; const TypeIcon = typeInfo.icon; + const ownerName = notebook.owner + ? `${notebook.owner.firstName} ${notebook.owner.lastName}` + : null; return ( {/* Public badge */} - {notebook.isPublic && ( + {notebook.isPublic && !isShared && ( + + {t('notebooks.shared')} + + )} + {/* Shared badge */} + {isShared && ( + - {t('notebooks.public')} + {t('notebooks.shared_notebook')} )} {/* Archive overlay */} @@ -237,6 +543,14 @@ function NotebookCard({ )}
+ {/* Owner name for shared notebooks */} + {isShared && ownerName && ( +
+ + {ownerName} +
+ )} + {/* Page count */}
@@ -251,27 +565,51 @@ function NotebookCard({ {t('notebooks.eco_tip')}
- {/* Action buttons */} -
- - -
+ {/* Action buttons — only show for own notebooks, not shared */} + {!isShared && ( +
+ {onShare && ( + + )} + {onDuplicate && ( + + )} + + +
+ )} @@ -291,7 +629,7 @@ function CreateNotebookDialog({ onClose: () => void; subjects: Subject[]; classes: ClassGroup[]; - onCreate: (data: Partial) => void; + onCreate: (data: Partial) => Promise; }) { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); @@ -517,6 +855,37 @@ function PageBackgroundPreview({ type }: { type: string }) { ); } +// ─── Page Thumbnail Component ──────────────────────────────────────── + +function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookType: string }) { + const bgType = page.background || notebookType; + const previewText = page.textContent?.substring(0, 80) ?? ''; + const hasTitle = !!page.title; + + return ( +
+
+ {hasTitle ? ( + {page.title} + ) : previewText ? ( + previewText + ) : ( + Leere Seite + )} +
+ {/* Bookmark indicator */} + {page.isBookmark && ( +
+ +
+ )} +
+ ); +} + // ─── Notebook Detail View ──────────────────────────────────────────── function NotebookDetailView({ @@ -532,7 +901,7 @@ function NotebookDetailView({ notebook: Notebook; subjectName: string | null; onBack: () => void; - onUpdatePage: (pageId: string, data: Partial) => void; + onUpdatePage: (pageId: string, data: Partial) => Promise; onAddPage: () => void; onDeletePage: (pageId: string) => void; onToggleBookmark: (pageId: string) => void; @@ -543,6 +912,12 @@ function NotebookDetailView({ const [pageTitle, setPageTitle] = useState(''); const [drawingMode, setDrawingMode] = useState(false); const [saving, setSaving] = useState(false); + const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + + const textareaRef = useRef(null); + const autoSaveTimerRef = useRef | null>(null); + const lastSavedContentRef = useRef(''); + const lastSavedTitleRef = useRef(''); const pages = notebook.pages ?? []; const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null; @@ -552,10 +927,14 @@ function NotebookDetailView({ setCurrentPageId(currentPage.id); setPageContent(currentPage.textContent ?? ''); setPageTitle(currentPage.title ?? ''); + lastSavedContentRef.current = currentPage.textContent ?? ''; + lastSavedTitleRef.current = currentPage.title ?? ''; } else if (pages.length > 0) { setCurrentPageId(pages[0].id); setPageContent(pages[0].textContent ?? ''); setPageTitle(pages[0].title ?? ''); + lastSavedContentRef.current = pages[0].textContent ?? ''; + lastSavedTitleRef.current = pages[0].title ?? ''; } }, [notebook.id]); @@ -563,18 +942,66 @@ function NotebookDetailView({ if (currentPage) { setPageContent(currentPage.textContent ?? ''); setPageTitle(currentPage.title ?? ''); + lastSavedContentRef.current = currentPage.textContent ?? ''; + lastSavedTitleRef.current = currentPage.title ?? ''; + setAutoSaveStatus('idle'); } }, [currentPageId]); + // Auto-save with 3 second debounce + const triggerAutoSave = useCallback(() => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + autoSaveTimerRef.current = setTimeout(async () => { + if (!currentPage) return; + const contentChanged = pageContent !== lastSavedContentRef.current; + const titleChanged = pageTitle !== lastSavedTitleRef.current; + if (!contentChanged && !titleChanged) return; + + setAutoSaveStatus('saving'); + try { + await onUpdatePage(currentPage.id, { + textContent: pageContent, + title: pageTitle.trim() || null, + }); + lastSavedContentRef.current = pageContent; + lastSavedTitleRef.current = pageTitle; + setAutoSaveStatus('saved'); + // Reset status after 2 seconds + setTimeout(() => setAutoSaveStatus('idle'), 2000); + } catch { + setAutoSaveStatus('idle'); + } + }, 3000); + }, [currentPage, pageContent, pageTitle, onUpdatePage]); + + // Trigger auto-save on content or title change + useEffect(() => { + triggerAutoSave(); + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + }; + }, [pageContent, pageTitle, triggerAutoSave]); + const handleSavePage = async () => { if (!currentPage) return; + // Cancel pending auto-save + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } setSaving(true); try { await onUpdatePage(currentPage.id, { textContent: pageContent, title: pageTitle.trim() || null, }); + lastSavedContentRef.current = pageContent; + lastSavedTitleRef.current = pageTitle; toast.success(t('notebooks.page_saved')); + setAutoSaveStatus('idle'); } catch { toast.error(t('notebooks.error_save')); } finally { @@ -615,8 +1042,8 @@ function NotebookDetailView({ {notebook.isPublic ? ( - - {t('notebooks.public')} + + {t('notebooks.shared')} ) : ( {t('notebooks.private')} @@ -624,6 +1051,21 @@ function NotebookDetailView({
+ {/* Auto-save indicator */} + {autoSaveStatus !== 'idle' && ( +
+ {autoSaveStatus === 'saving' && ( +
+ )} + {autoSaveStatus === 'saved' && ( + + )} + + {autoSaveStatus === 'saving' ? t('notebooks.auto_saving') : t('notebooks.auto_saved')} + +
+ )} + {/* Drawing mode toggle */}
- -
+ +
{pages.map((page, idx) => ( + {/* Page thumbnail */} + + + {/* Page info below thumbnail */} @@ -700,11 +1153,17 @@ function NotebookDetailView({
- {/* Eco message in sidebar */} + {/* Page count footer */}
-
- - {t('notebooks.eco_message')} +
+ + + {pages.length} {t('notebooks.pages')} + + + + {t('notebooks.eco_message')} +
@@ -761,26 +1220,33 @@ function NotebookDetailView({
) : ( - /* Text editing area with page background */ -
-
- -
-