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:
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(),
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user