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
This commit is contained in:
Mustafa Cetin committed 2026-07-29 08:02:04 +00:00
1 parent 04034dca62
commit 62f4a150e0
14 files changed
+1983 -166

No files matched your search

@@ -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 }
);
}
}
@@ -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(),
+99 -4
View File
@@ -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<string, unknown> = {
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<string, unknown> = {
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 } },
+49
View File
@@ -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 }
);
}
}