From 04034dca6215df9097be822a7e689369c22dee9f Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 06:39:40 +0000 Subject: [PATCH] CompetenceTrack v8 - Complete UI/UX redesign, Digital Notebooks, Drawing Canvas, Demo Data Cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Complete UI/UX redesign for tablet/stylus optimization - Digital Notebook system (lined, grid, blank, dotted, music, calligraphy) - Drawing Canvas with stylus/pen support, pressure sensitivity - Art class mode with tutorial guides and shape templates - Demo data one-click cleanup for production transition - Subject-specific notebook filtering - Environmental messaging (Papier sparen, Umwelt schützen) - 44px minimum touch targets for tablet - No emojis - Lucide icons throughout - 700+ i18n keys (German/English) - Notebook types: lined, grid, blank, dotted, music, calligraphy - Drawing tools: pencil, pen, line, rectangle, circle, eraser - Undo/redo, color picker, stroke width, export PNG - Archive/unarchive notebooks - White backgrounds throughout - Emerald accent color theme Backend: - Notebook CRUD API routes - Drawing CRUD API routes - Demo data cleanup API - Prisma schema with Notebook, NotebookPage, Drawing models - isDemo flags on all new models --- prisma/schema.prisma | 89 ++ src/app/api/demo-data/route.ts | 165 +++ src/app/api/drawings/[id]/route.ts | 126 ++ src/app/api/drawings/route.ts | 96 ++ .../notebooks/[id]/pages/[pageId]/route.ts | 145 ++ src/app/api/notebooks/[id]/pages/route.ts | 157 ++ src/app/api/notebooks/[id]/route.ts | 194 +++ src/app/api/notebooks/route.ts | 137 ++ src/app/globals.css | 133 ++ src/components/analytics-view.tsx | 2 +- src/components/app-layout.tsx | 46 +- src/components/auth-view.tsx | 43 +- src/components/behavior-tracking-view.tsx | 24 +- src/components/comment-bank-view.tsx | 19 +- src/components/dashboard-view.tsx | 45 +- src/components/drawing-canvas.tsx | 1149 +++++++++++++++ src/components/drawing-view.tsx | 516 +++++++ src/components/notebooks-view.tsx | 1268 +++++++++++++++++ src/components/parent-communication-view.tsx | 2 +- src/components/settings-view.tsx | 65 +- src/lib/i18n.ts | 344 +++++ src/lib/store.ts | 4 +- 22 files changed, 4716 insertions(+), 53 deletions(-) create mode 100644 src/app/api/demo-data/route.ts create mode 100644 src/app/api/drawings/[id]/route.ts create mode 100644 src/app/api/drawings/route.ts create mode 100644 src/app/api/notebooks/[id]/pages/[pageId]/route.ts create mode 100644 src/app/api/notebooks/[id]/pages/route.ts create mode 100644 src/app/api/notebooks/[id]/route.ts create mode 100644 src/app/api/notebooks/route.ts create mode 100644 src/components/drawing-canvas.tsx create mode 100644 src/components/drawing-view.tsx create mode 100644 src/components/notebooks-view.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index edc9283..613abbc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,8 @@ model School { rubrics Rubric[] commentCategories CommentCategory[] commentBankEntries CommentBankEntry[] + notebooks Notebook[] + drawings Drawing[] } model User { @@ -67,6 +69,8 @@ model User { behaviorIncidentsResolved BehaviorIncident[] @relation("BehaviorResolver") rubrics Rubric[] commentBankEntries CommentBankEntry[] + notebooks Notebook[] + drawings Drawing[] } model SchoolYear { @@ -109,6 +113,8 @@ model ClassGroup { attendanceSessions AttendanceSession[] lessonPlans LessonPlan[] behaviorIncidents BehaviorIncident[] + notebooks Notebook[] + drawings Drawing[] } model ClassGroupTeacher { @@ -179,6 +185,8 @@ model Subject { lessonPlans LessonPlan[] rubrics Rubric[] commentBankEntries CommentBankEntry[] + notebooks Notebook[] + drawings Drawing[] } model CompetencyTemplate { @@ -711,3 +719,84 @@ model CommentBankEntry { @@index([categoryId]) @@index([subjectId]) } + +// ─── Digital Notebooks ───────────────────────────────────────────── + +model Notebook { + id String @id @default(cuid()) + schoolId String + ownerId String // userId of the student or teacher + ownerType String // STUDENT | TEACHER + subjectId String? // null = personal/general notebook + classGroupId String? + title String + description String? + notebookType String @default("lined") // lined | grid | blank | dotted | music | calligraphy + color String @default("#10b981") // cover color + icon String? // lucide icon name + isArchived Boolean @default(false) + isPublic Boolean @default(false) // shared with class + sortOrder Int @default(0) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + owner User @relation(fields: [ownerId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + pages NotebookPage[] + + @@index([schoolId]) + @@index([ownerId]) + @@index([subjectId]) + @@index([classGroupId]) + @@index([isArchived]) +} + +model NotebookPage { + id String @id @default(cuid()) + notebookId String + pageNumber Int + title String? + contentType String @default("text") // text | drawing | mixed + textContent String? // markdown/rich text content + drawingData String? // JSON with strokes/paths for canvas + background String @default("lined") // lined | grid | blank | dotted | music + isBookmark Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) + + @@index([notebookId]) + @@index([pageNumber]) +} + +// ─── Drawing Canvas ──────────────────────────────────────────────── + +model Drawing { + id String @id @default(cuid()) + schoolId String + ownerId String + title String + description String? + drawingData String // JSON with all strokes, paths, colors + imageData String? // base64 encoded PNG thumbnail + subjectId String? + classGroupId String? + isPublic Boolean @default(false) + isDemo Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + owner User @relation(fields: [ownerId], references: [id]) + subject Subject? @relation(fields: [subjectId], references: [id]) + classGroup ClassGroup? @relation(fields: [classGroupId], references: [id]) + + @@index([schoolId]) + @@index([ownerId]) + @@index([subjectId]) +} diff --git a/src/app/api/demo-data/route.ts b/src/app/api/demo-data/route.ts new file mode 100644 index 0000000..0aa5316 --- /dev/null +++ b/src/app/api/demo-data/route.ts @@ -0,0 +1,165 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get demo data statistics ── +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Only SCHOOL_ADMIN or SUPER_ADMIN can view demo data stats + if (!['SCHOOL_ADMIN', 'SUPER_ADMIN'].includes(session.user.role)) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Get demo user IDs for cascade counting + const demoUsers = await db.user.findMany({ + where: { isDemo: true }, + select: { id: true }, + }); + const demoUserIds = demoUsers.map((u) => u.id); + + const [ + demoUserCount, + demoNotebookCount, + demoDrawingCount, + cascadeNotebookCount, + cascadeDrawingCount, + ] = await Promise.all([ + db.user.count({ where: { isDemo: true } }), + db.notebook.count({ where: { isDemo: true } }), + db.drawing.count({ where: { isDemo: true } }), + demoUserIds.length > 0 + ? db.notebook.count({ + where: { ownerId: { in: demoUserIds }, isDemo: false }, + }) + : 0, + demoUserIds.length > 0 + ? db.drawing.count({ + where: { ownerId: { in: demoUserIds }, isDemo: false }, + }) + : 0, + ]); + + return NextResponse.json({ + User: demoUserCount, + Notebook: demoNotebookCount, + Drawing: demoDrawingCount, + _cascadeNotebooks: cascadeNotebookCount, + _cascadeDrawings: cascadeDrawingCount, + _total: + demoUserCount + + demoNotebookCount + + demoDrawingCount + + cascadeNotebookCount + + cascadeDrawingCount, + }); + } catch (error) { + console.error('Demo data stats error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── DELETE: Delete ALL demo data ── +export async function DELETE() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // Only SCHOOL_ADMIN or SUPER_ADMIN can delete demo data + if (!['SCHOOL_ADMIN', 'SUPER_ADMIN'].includes(session.user.role)) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const summary: Record = {}; + + // 1. Get all demo user IDs for cascade deletion + const demoUsers = await db.user.findMany({ + where: { isDemo: true }, + select: { id: true }, + }); + const demoUserIds = demoUsers.map((u) => u.id); + + // 2. Delete NotebookPages for notebooks owned by demo users + // (NotebookPage has cascade delete on Notebook, but we need to handle + // notebooks where isDemo=true OR ownerId is a demo user) + const demoNotebookIds = await db.notebook.findMany({ + where: { + OR: [{ isDemo: true }, { ownerId: { in: demoUserIds } }], + }, + select: { id: true }, + }); + const demoNotebookIdList = demoNotebookIds.map((n) => n.id); + + let deletedPages = 0; + if (demoNotebookIdList.length > 0) { + deletedPages = (await db.notebookPage.deleteMany({ + where: { notebookId: { in: demoNotebookIdList } }, + })) as unknown as number; + summary.NotebookPage = typeof deletedPages === 'number' ? deletedPages : (deletedPages as { count: number }).count ?? 0; + } else { + summary.NotebookPage = 0; + } + + // 3. Delete notebooks where isDemo=true OR ownerId is a demo user + const deletedNotebooks = await db.notebook.deleteMany({ + where: { + OR: [{ isDemo: true }, { ownerId: { in: demoUserIds } }], + }, + }); + summary.Notebook = + typeof deletedNotebooks === 'number' + ? deletedNotebooks + : (deletedNotebooks as { count: number }).count ?? 0; + + // 4. Delete drawings where isDemo=true OR ownerId is a demo user + const deletedDrawings = await db.drawing.deleteMany({ + where: { + OR: [{ isDemo: true }, { ownerId: { in: demoUserIds } }], + }, + }); + summary.Drawing = + typeof deletedDrawings === 'number' + ? deletedDrawings + : (deletedDrawings as { count: number }).count ?? 0; + + // 5. Delete demo users (soft-deleted users that are demo) + const deletedUsers = await db.user.deleteMany({ + where: { isDemo: true }, + }); + summary.User = + typeof deletedUsers === 'number' + ? deletedUsers + : (deletedUsers as { count: number }).count ?? 0; + + // 6. Log the cleanup in AuditLog + await db.auditLog.create({ + data: { + userId: session.user.id, + schoolId: session.user.schoolId, + action: 'DEMO_DATA_CLEANUP', + entityType: 'System', + metadata: JSON.stringify(summary), + }, + }); + + return NextResponse.json({ + message: 'Demo data cleanup completed', + deleted: summary, + }); + } catch (error) { + console.error('Demo data cleanup error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/drawings/[id]/route.ts b/src/app/api/drawings/[id]/route.ts new file mode 100644 index 0000000..f04e790 --- /dev/null +++ b/src/app/api/drawings/[id]/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const updateDrawingSchema = z.object({ + title: z.string().min(1).optional(), + description: z.string().nullable().optional(), + drawingData: z.string().optional(), + imageData: z.string().nullable().optional(), + subjectId: z.string().nullable().optional(), + classGroupId: z.string().nullable().optional(), + isPublic: z.boolean().optional(), +}); + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + const drawing = await db.drawing.findUnique({ + where: { id }, + }); + + if (!drawing) { + return NextResponse.json({ error: 'Drawing not found' }, { status: 404 }); + } + + // Only owner or public drawings + if (drawing.ownerId !== session.userId && !drawing.isPublic) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + return NextResponse.json(drawing); + } catch (error) { + console.error('Drawing GET by ID error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + + // Check ownership + const existing = await db.drawing.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Drawing not found' }, { status: 404 }); + } + if (existing.ownerId !== session.userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = updateDrawingSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const drawing = await db.drawing.update({ + where: { id }, + data: parsed.data, + }); + + return NextResponse.json(drawing); + } catch (error) { + console.error('Drawing PUT error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id } = await params; + + // Check ownership + const existing = await db.drawing.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: 'Drawing not found' }, { status: 404 }); + } + if (existing.ownerId !== session.userId && session.user?.role !== 'SUPER_ADMIN' && session.user?.role !== 'SCHOOL_ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await db.drawing.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Drawing DELETE error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/drawings/route.ts b/src/app/api/drawings/route.ts new file mode 100644 index 0000000..bf25272 --- /dev/null +++ b/src/app/api/drawings/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +const createDrawingSchema = z.object({ + title: z.string().min(1).default('Untitled Drawing'), + description: z.string().optional(), + drawingData: z.string().min(1), + imageData: z.string().optional(), + schoolId: z.string().optional(), + subjectId: z.string().optional(), + classGroupId: z.string().optional(), + isPublic: z.boolean().default(false), +}); + +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined; + const subjectId = searchParams.get('subjectId'); + + const where: Record = {}; + if (schoolId) { + where.schoolId = schoolId; + } + if (subjectId) { + where.subjectId = subjectId; + } + + // Show own drawings + public drawings from the same school + const drawings = await db.drawing.findMany({ + where: { + OR: [ + { ownerId: session.userId }, + { isPublic: true, ...(schoolId ? { schoolId } : {}) }, + ], + ...(subjectId ? { subjectId } : {}), + }, + orderBy: { updatedAt: 'desc' }, + }); + + return NextResponse.json(drawings); + } catch (error) { + console.error('Drawings GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const parsed = createDrawingSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const drawing = await db.drawing.create({ + data: { + schoolId: parsed.data.schoolId ?? session.user?.schoolId ?? 'default', + ownerId: session.userId, + title: parsed.data.title, + description: parsed.data.description ?? null, + drawingData: parsed.data.drawingData, + imageData: parsed.data.imageData ?? null, + subjectId: parsed.data.subjectId ?? null, + classGroupId: parsed.data.classGroupId ?? null, + isPublic: parsed.data.isPublic, + }, + }); + + return NextResponse.json(drawing, { status: 201 }); + } catch (error) { + console.error('Drawings POST 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 new file mode 100644 index 0000000..9741d48 --- /dev/null +++ b/src/app/api/notebooks/[id]/pages/[pageId]/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── PUT: Update a page ── +const updatePageSchema = z.object({ + textContent: z.string().optional().nullable(), + drawingData: z.string().optional().nullable(), + background: z + .enum(['lined', 'grid', 'blank', 'dotted', 'music']) + .optional(), + isBookmark: z.boolean().optional(), + title: z.string().optional().nullable(), +}); + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string; pageId: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id, pageId } = await params; + + const page = await db.notebookPage.findUnique({ + where: { id: pageId }, + include: { notebook: true }, + }); + + if (!page || page.notebookId !== id) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + if (page.notebook.deletedAt) { + return NextResponse.json( + { error: 'Notebook has been deleted' }, + { status: 400 } + ); + } + + if (page.notebook.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = updatePageSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const updatedPage = await db.notebookPage.update({ + where: { id: pageId }, + data: parsed.data, + }); + + // Audit log + await db.auditLog.create({ + data: { + userId: session.user.id, + schoolId: page.notebook.schoolId, + action: 'UPDATE', + entityType: 'NotebookPage', + entityId: pageId, + metadata: JSON.stringify({ + notebookId: id, + pageNumber: page.pageNumber, + updatedFields: Object.keys(parsed.data), + }), + }, + }); + + return NextResponse.json(updatedPage); + } catch (error) { + console.error('Notebook page update error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── DELETE: Delete a page ── +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; pageId: string }> } +) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { id, pageId } = await params; + + const page = await db.notebookPage.findUnique({ + where: { id: pageId }, + include: { notebook: true }, + }); + + if (!page || page.notebookId !== id) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + if (page.notebook.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + await db.notebookPage.delete({ + where: { id: pageId }, + }); + + // Audit log + await db.auditLog.create({ + data: { + userId: session.user.id, + schoolId: page.notebook.schoolId, + action: 'DELETE', + entityType: 'NotebookPage', + entityId: pageId, + metadata: JSON.stringify({ + notebookId: id, + pageNumber: page.pageNumber, + }), + }, + }); + + return NextResponse.json({ + message: 'Page deleted', + id: pageId, + }); + } catch (error) { + console.error('Notebook page delete error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notebooks/[id]/pages/route.ts b/src/app/api/notebooks/[id]/pages/route.ts new file mode 100644 index 0000000..17b182e --- /dev/null +++ b/src/app/api/notebooks/[id]/pages/route.ts @@ -0,0 +1,157 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List pages in a notebook ── +export async function GET( + 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; + + const notebook = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!notebook) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + // Access check + if ( + notebook.ownerId !== session.user.id && + !(notebook.isPublic && notebook.schoolId === session.user.schoolId) + ) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const pages = await db.notebookPage.findMany({ + where: { notebookId: id }, + orderBy: { pageNumber: 'asc' }, + }); + + return NextResponse.json(pages); + } catch (error) { + console.error('Notebook pages list error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── POST: Add a new page to a notebook ── +const createPageSchema = z.object({ + pageNumber: z.number().int().optional(), + contentType: z.enum(['text', 'drawing', 'mixed']).default('text'), + textContent: z.string().optional().nullable(), + drawingData: z.string().optional().nullable(), + background: z + .enum(['lined', 'grid', 'blank', 'dotted', 'music']) + .default('lined'), + title: z.string().optional().nullable(), +}); + +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; + + const notebook = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!notebook) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + if (notebook.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createPageSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + // Auto-increment pageNumber if not provided + let pageNumber = parsed.data.pageNumber; + if (!pageNumber) { + const maxPage = await db.notebookPage.findFirst({ + where: { notebookId: id }, + orderBy: { pageNumber: 'desc' }, + select: { pageNumber: true }, + }); + pageNumber = maxPage ? maxPage.pageNumber + 1 : 1; + } + + // Use notebook's notebookType as default background if not specified differently + const background = + parsed.data.background || + (['lined', 'grid', 'blank', 'dotted', 'music'].includes( + notebook.notebookType + ) + ? notebook.notebookType + : 'lined'); + + const page = await db.notebookPage.create({ + data: { + notebookId: id, + pageNumber, + title: parsed.data.title, + contentType: parsed.data.contentType, + textContent: parsed.data.textContent, + drawingData: parsed.data.drawingData, + background, + }, + }); + + // Audit log + await db.auditLog.create({ + data: { + userId: session.user.id, + schoolId: notebook.schoolId, + action: 'CREATE', + entityType: 'NotebookPage', + entityId: page.id, + metadata: JSON.stringify({ + notebookId: id, + pageNumber, + contentType: parsed.data.contentType, + }), + }, + }); + + return NextResponse.json(page, { status: 201 }); + } catch (error) { + console.error('Notebook page create error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notebooks/[id]/route.ts b/src/app/api/notebooks/[id]/route.ts new file mode 100644 index 0000000..d8f67f9 --- /dev/null +++ b/src/app/api/notebooks/[id]/route.ts @@ -0,0 +1,194 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: Get a single notebook with its pages ── +export async function GET( + 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; + + const notebook = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + pages: { + orderBy: { pageNumber: 'asc' }, + }, + owner: { select: { id: true, firstName: true, lastName: true } }, + }, + }); + + if (!notebook) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + // Access check: owner or public notebook in the same school + if ( + notebook.ownerId !== session.user.id && + !(notebook.isPublic && notebook.schoolId === session.user.schoolId) + ) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + return NextResponse.json(notebook); + } catch (error) { + console.error('Notebook get error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── PUT: Update notebook ── +const updateNotebookSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional().nullable(), + notebookType: z + .enum(['lined', 'grid', 'blank', 'dotted', 'music', 'calligraphy']) + .optional(), + color: z.string().optional(), + icon: z.string().optional().nullable(), + isArchived: z.boolean().optional(), + isPublic: z.boolean().optional(), + sortOrder: z.number().int().optional(), +}); + +export async function PUT( + 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; + + const existing = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + if (existing.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = updateNotebookSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const notebook = await db.notebook.update({ + where: { id }, + data: parsed.data, + 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: existing.schoolId, + action: 'UPDATE', + entityType: 'Notebook', + entityId: id, + metadata: JSON.stringify({ updatedFields: Object.keys(parsed.data) }), + }, + }); + + return NextResponse.json(notebook); + } catch (error) { + console.error('Notebook update error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── DELETE: Soft delete notebook ── +export async function DELETE( + 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; + + const existing = await db.notebook.findUnique({ + where: { id, deletedAt: null }, + }); + + if (!existing) { + return NextResponse.json( + { error: 'Notebook not found' }, + { status: 404 } + ); + } + + if (existing.ownerId !== session.user.id) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const deleted = await db.notebook.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + // Audit log + await db.auditLog.create({ + data: { + userId: session.user.id, + schoolId: existing.schoolId, + action: 'DELETE', + entityType: 'Notebook', + entityId: id, + metadata: JSON.stringify({ title: existing.title }), + }, + }); + + return NextResponse.json({ + message: 'Notebook deleted', + id: deleted.id, + }); + } catch (error) { + console.error('Notebook delete error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts new file mode 100644 index 0000000..cd71f6a --- /dev/null +++ b/src/app/api/notebooks/route.ts @@ -0,0 +1,137 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ── GET: List notebooks for the current user ── +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'); + const subjectId = searchParams.get('subjectId'); + const classGroupId = searchParams.get('classGroupId'); + const isArchived = searchParams.get('isArchived'); + + const where: Record = { + ownerId: session.user.id, + deletedAt: null, + }; + + if (schoolId) where.schoolId = schoolId; + if (subjectId) where.subjectId = subjectId; + if (classGroupId) where.classGroupId = classGroupId; + if (isArchived !== null && isArchived !== undefined) { + where.isArchived = isArchived === 'true'; + } + + // Default schoolId from user session + if (!schoolId && session.user.schoolId) { + where.schoolId = session.user.schoolId; + } + + const notebooks = await db.notebook.findMany({ + where, + include: { + subject: { select: { id: true, name: true } }, + classGroup: { select: { id: true, name: true } }, + _count: { select: { pages: true } }, + }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + }); + + return NextResponse.json(notebooks); + } catch (error) { + console.error('Notebooks list error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// ── POST: Create a new notebook ── +const createNotebookSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(1000).optional().nullable(), + notebookType: z + .enum(['lined', 'grid', 'blank', 'dotted', 'music', 'calligraphy']) + .default('lined'), + color: z.string().default('#10b981'), + icon: z.string().optional().nullable(), + subjectId: z.string().optional().nullable(), + classGroupId: z.string().optional().nullable(), +}); + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const parsed = createNotebookSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { title, description, notebookType, color, icon, subjectId, classGroupId } = + parsed.data; + + const schoolId = session.user.schoolId; + if (!schoolId) { + return NextResponse.json( + { error: 'User must belong to a school to create notebooks' }, + { status: 400 } + ); + } + + const notebook = await db.notebook.create({ + data: { + schoolId, + ownerId: session.user.id, + ownerType: 'TEACHER', + title, + description, + notebookType, + color, + icon, + subjectId, + classGroupId, + }, + 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, + action: 'CREATE', + entityType: 'Notebook', + entityId: notebook.id, + metadata: JSON.stringify({ title }), + }, + }); + + return NextResponse.json(notebook, { status: 201 }); + } catch (error) { + console.error('Notebook create error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index d0a20e4..238e3ff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -633,3 +633,136 @@ /* Prevent overflow */ .overflow-y-auto, .max-h-96, .max-h-80, .max-h-72 { max-height: none !important; overflow: visible !important; } } + +/* ─── Tablet & Stylus Optimization ────────────────────────────────────── */ + +/* Minimum touch target size (44px as per Apple HIG and WCAG guidelines) */ +@layer utilities { + .min-touch { + min-width: 44px; + min-height: 44px; + } +} + +/* Touch-friendly media queries for tablets (768px-1024px) */ +@media (pointer: coarse) { + /* Increase touch targets for all interactive elements */ + button, + [role="button"], + a, + input[type="checkbox"], + input[type="radio"], + select, + .tabs-trigger, + [data-state] { + min-height: 44px; + } + + /* Stylus-friendly: larger focus rings to prevent accidental taps */ + *:focus-visible { + outline-width: 3px; + outline-offset: 4px; + } + + /* More generous spacing between interactive elements */ + .sidebar-menu-item { + margin-bottom: 2px; + } + + /* Prevent text selection while using stylus on interactive elements */ + button, a, [role="button"] { + -webkit-user-select: none; + user-select: none; + } + + /* Slightly larger fonts for touch readability */ + input, select, textarea { + font-size: 16px; /* Prevents iOS zoom on input focus */ + } +} + +/* Stylus-specific improvements */ +@media (pointer: fine) and (hover: none) { + /* Stylus devices often lack hover — replace hover effects with active */ + *:hover { + /* Remove hover-only styles that don't make sense without hover capability */ + } + /* Larger active/focus states for stylus precision */ + *:active { + transform: scale(0.98); + transition: transform 0.1s ease; + } +} + +/* ─── Smooth Scrollbar Styling (touch-friendly) ─────────────────────── */ +@layer utilities { + .scrollbar-touch::-webkit-scrollbar { + width: 10px; + height: 10px; + } + .scrollbar-touch::-webkit-scrollbar-track { + background: transparent; + margin: 4px; + } + .scrollbar-touch::-webkit-scrollbar-thumb { + background: oklch(0.80 0.02 155 / 0.5); + border-radius: 5px; + border: 3px solid transparent; + background-clip: padding-box; + } + .scrollbar-touch::-webkit-scrollbar-thumb:hover { + background: oklch(0.65 0.17 155 / 0.7); + border: 3px solid transparent; + background-clip: padding-box; + } + .dark .scrollbar-touch::-webkit-scrollbar-thumb { + background: oklch(0.35 0.015 260 / 0.5); + border: 3px solid transparent; + background-clip: padding-box; + } + + /* Stylus-friendly input styles — larger focus rings for precision tapping */ + .stylus-input:focus-visible { + outline: 3px solid oklch(0.55 0.17 155); + outline-offset: 4px; + box-shadow: 0 0 0 6px oklch(0.55 0.17 155 / 0.15); + border-radius: 8px; + transition: box-shadow 0.2s ease-out, outline-offset 0.2s ease-out; + } + .dark .stylus-input:focus-visible { + outline: 3px solid oklch(0.65 0.17 155); + box-shadow: 0 0 0 6px oklch(0.65 0.17 155 / 0.2); + } +} + +/* ─── Better Print Styles ──────────────────────────────────────────────── */ +@media print { + /* Ensure emerald accent colors print as dark gray for readability */ + .text-emerald-600, .text-emerald-700, .text-teal-600, .text-teal-700 { + color: #333 !important; + } + .bg-emerald-50, .bg-emerald-100, .bg-teal-50, .bg-teal-100, + .bg-gradient-to-r, + [class*="from-emerald"], + [class*="from-teal"] { + background: #f5f5f5 !important; + } + + /* 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 { + animation: none !important; + } + + /* Force all cards to have visible borders */ + [class*="border-0"], [class*="border-l-3"] { + border: 1px solid #ddd !important; + } + + /* Remove shadows */ + .shadow-sm, .shadow-md, .shadow-lg, .shadow-xl, + [class*="shadow-"] { + box-shadow: none !important; + } +} diff --git a/src/components/analytics-view.tsx b/src/components/analytics-view.tsx index af9fc38..c089e14 100644 --- a/src/components/analytics-view.tsx +++ b/src/components/analytics-view.tsx @@ -532,7 +532,7 @@ export default function AnalyticsView() { <> {/* Insights */} {(() => { - const insights: Array<{ icon: string; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = []; + const insights: Array<{ icon: React.ReactNode; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = []; // Trend insight: compare first vs last mastery data point if (data.masteryTrend.length >= 2) { const first = data.masteryTrend[0].avgMastery; diff --git a/src/components/app-layout.tsx b/src/components/app-layout.tsx index 29d713b..97bbc81 100644 --- a/src/components/app-layout.tsx +++ b/src/components/app-layout.tsx @@ -40,6 +40,8 @@ import { MessageSquareText, Pencil, Plus, + Palette, + Leaf, } from 'lucide-react'; import { Sidebar, @@ -102,8 +104,8 @@ import { CommandShortcut, } from '@/components/ui/command'; import { toast } from 'sonner'; - import { AlertTriangle, Info, ArrowRight } from 'lucide-react'; + import DashboardView from './dashboard-view'; import ClassesView from './classes-view'; import CompetencyGridView from './competency-grid-view'; @@ -124,6 +126,8 @@ import BehaviorTrackingView from './behavior-tracking-view'; import CurriculumCoverageView from './curriculum-coverage-view'; import RubricLibraryView from './rubric-library-view'; import CommentBankView from './comment-bank-view'; +import NotebooksView from './notebooks-view'; +import DrawingView from './drawing-view'; type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string }; type NavSection = { id: string; labelKey: string; items: NavItem[] }; @@ -156,6 +160,8 @@ const navSections: NavSection[] = [ { key: 'behavior', icon: Shield, labelKey: 'nav.behavior' }, { key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' }, { key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' }, + { key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' }, + { key: 'drawing', icon: Palette, labelKey: 'nav.drawing' }, ], }, { @@ -190,6 +196,8 @@ function renderView(view: ViewName) { case 'coverage': return ; case 'rubrics': return ; case 'comments': return ; + case 'notebooks': return ; + case 'drawing': return ; default: return ; } } @@ -469,7 +477,7 @@ export default function AppLayout() { isActive={currentView === item.key} onClick={() => setCurrentView(item.key)} tooltip={t(item.labelKey)} - className={`group relative transition-all duration-200 ${ + className={`group relative transition-all duration-200 min-h-[44px] ${ currentView === item.key ? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm shadow-emerald-100/40 dark:shadow-emerald-900/20' : 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10' @@ -478,12 +486,12 @@ export default function AppLayout() { {currentView === item.key && ( )} - - {t(item.labelKey)} + {t(item.labelKey)} ))} @@ -494,9 +502,21 @@ export default function AppLayout() { + {/* Environmental message */} + + +

+ {t('sidebar.eco_message')} +

+
- + {initials} @@ -517,7 +537,7 @@ export default function AppLayout() {
-