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 }
);
}
}
+118 -1
View File
@@ -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
);
}
}