CompetenceTrack v8 - Complete UI/UX redesign, Digital Notebooks, Drawing Canvas, Demo Data Cleanup

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
This commit is contained in:
Mustafa Cetin committed 2026-07-29 06:39:40 +00:00
1 parent 456cdf9714
commit 04034dca62
22 files changed
+4714 -51

No files matched your search

+89
View File
@@ -35,6 +35,8 @@ model School {
rubrics Rubric[] rubrics Rubric[]
commentCategories CommentCategory[] commentCategories CommentCategory[]
commentBankEntries CommentBankEntry[] commentBankEntries CommentBankEntry[]
notebooks Notebook[]
drawings Drawing[]
} }
model User { model User {
@@ -67,6 +69,8 @@ model User {
behaviorIncidentsResolved BehaviorIncident[] @relation("BehaviorResolver") behaviorIncidentsResolved BehaviorIncident[] @relation("BehaviorResolver")
rubrics Rubric[] rubrics Rubric[]
commentBankEntries CommentBankEntry[] commentBankEntries CommentBankEntry[]
notebooks Notebook[]
drawings Drawing[]
} }
model SchoolYear { model SchoolYear {
@@ -109,6 +113,8 @@ model ClassGroup {
attendanceSessions AttendanceSession[] attendanceSessions AttendanceSession[]
lessonPlans LessonPlan[] lessonPlans LessonPlan[]
behaviorIncidents BehaviorIncident[] behaviorIncidents BehaviorIncident[]
notebooks Notebook[]
drawings Drawing[]
} }
model ClassGroupTeacher { model ClassGroupTeacher {
@@ -179,6 +185,8 @@ model Subject {
lessonPlans LessonPlan[] lessonPlans LessonPlan[]
rubrics Rubric[] rubrics Rubric[]
commentBankEntries CommentBankEntry[] commentBankEntries CommentBankEntry[]
notebooks Notebook[]
drawings Drawing[]
} }
model CompetencyTemplate { model CompetencyTemplate {
@@ -711,3 +719,84 @@ model CommentBankEntry {
@@index([categoryId]) @@index([categoryId])
@@index([subjectId]) @@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])
}
+165
View File
@@ -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<string, number> = {};
// 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 }
);
}
}
+126
View File
@@ -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 }
);
}
}
+96
View File
@@ -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<string, unknown> = {};
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 }
);
}
}
@@ -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 }
);
}
}
+157
View File
@@ -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 }
);
}
}
+194
View File
@@ -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 }
);
}
}
+137
View File
@@ -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<string, unknown> = {
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 }
);
}
}
+133
View File
@@ -633,3 +633,136 @@
/* Prevent overflow */ /* Prevent overflow */
.overflow-y-auto, .max-h-96, .max-h-80, .max-h-72 { max-height: none !important; overflow: visible !important; } .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;
}
}
+1 -1
View File
@@ -532,7 +532,7 @@ export default function AnalyticsView() {
<> <>
{/* Insights */} {/* 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 // Trend insight: compare first vs last mastery data point
if (data.masteryTrend.length >= 2) { if (data.masteryTrend.length >= 2) {
const first = data.masteryTrend[0].avgMastery; const first = data.masteryTrend[0].avgMastery;
+33 -13
View File
@@ -40,6 +40,8 @@ import {
MessageSquareText, MessageSquareText,
Pencil, Pencil,
Plus, Plus,
Palette,
Leaf,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Sidebar, Sidebar,
@@ -102,8 +104,8 @@ import {
CommandShortcut, CommandShortcut,
} from '@/components/ui/command'; } from '@/components/ui/command';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AlertTriangle, Info, ArrowRight } from 'lucide-react'; import { AlertTriangle, Info, ArrowRight } from 'lucide-react';
import DashboardView from './dashboard-view'; import DashboardView from './dashboard-view';
import ClassesView from './classes-view'; import ClassesView from './classes-view';
import CompetencyGridView from './competency-grid-view'; import CompetencyGridView from './competency-grid-view';
@@ -124,6 +126,8 @@ import BehaviorTrackingView from './behavior-tracking-view';
import CurriculumCoverageView from './curriculum-coverage-view'; import CurriculumCoverageView from './curriculum-coverage-view';
import RubricLibraryView from './rubric-library-view'; import RubricLibraryView from './rubric-library-view';
import CommentBankView from './comment-bank-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 NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
type NavSection = { id: string; labelKey: string; items: NavItem[] }; type NavSection = { id: string; labelKey: string; items: NavItem[] };
@@ -156,6 +160,8 @@ const navSections: NavSection[] = [
{ key: 'behavior', icon: Shield, labelKey: 'nav.behavior' }, { key: 'behavior', icon: Shield, labelKey: 'nav.behavior' },
{ key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' }, { key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' },
{ key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' }, { 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 <CurriculumCoverageView />; case 'coverage': return <CurriculumCoverageView />;
case 'rubrics': return <RubricLibraryView />; case 'rubrics': return <RubricLibraryView />;
case 'comments': return <CommentBankView />; case 'comments': return <CommentBankView />;
case 'notebooks': return <NotebooksView />;
case 'drawing': return <DrawingView />;
default: return <DashboardView />; default: return <DashboardView />;
} }
} }
@@ -469,7 +477,7 @@ export default function AppLayout() {
isActive={currentView === item.key} isActive={currentView === item.key}
onClick={() => setCurrentView(item.key)} onClick={() => setCurrentView(item.key)}
tooltip={t(item.labelKey)} tooltip={t(item.labelKey)}
className={`group relative transition-all duration-200 ${ className={`group relative transition-all duration-200 min-h-[44px] ${
currentView === item.key 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' ? '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' : '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 && ( {currentView === item.key && (
<span className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500" /> <span className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500" />
)} )}
<item.icon className={`h-4 w-4 transition-colors duration-200 ${ <item.icon className={`h-5 w-5 transition-colors duration-200 ${
currentView === item.key currentView === item.key
? 'text-emerald-600 dark:text-emerald-400' ? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400' : 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400'
}`} /> }`} />
<span>{t(item.labelKey)}</span> <span className="text-sm">{t(item.labelKey)}</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
@@ -494,9 +502,21 @@ export default function AppLayout() {
</SidebarContent> </SidebarContent>
<SidebarFooter className="p-3"> <SidebarFooter className="p-3">
{/* Environmental message */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-lg bg-gradient-to-r from-emerald-50/80 via-teal-50/40 to-transparent dark:from-emerald-900/15 dark:via-teal-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-800/20 group-data-[collapsible=icon]:hidden"
>
<Leaf className="h-4 w-4 text-emerald-500 shrink-0" />
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50 font-medium leading-snug">
{t('sidebar.eco_message')}
</p>
</motion.div>
<Separator className="mb-3 bg-emerald-200/50 dark:bg-emerald-900/30" /> <Separator className="mb-3 bg-emerald-200/50 dark:bg-emerald-900/30" />
<div className="flex items-center gap-2 group-data-[collapsible=icon]:justify-center"> <div className="flex items-center gap-2 group-data-[collapsible=icon]:justify-center">
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-emerald-200 dark:ring-emerald-800"> <Avatar className="h-10 w-10 shrink-0 ring-2 ring-emerald-200 dark:ring-emerald-800">
<AvatarFallback className="bg-gradient-to-br from-emerald-100 to-teal-100 text-emerald-700 dark:from-emerald-900 dark:to-teal-900 dark:text-emerald-300 text-xs font-bold"> <AvatarFallback className="bg-gradient-to-br from-emerald-100 to-teal-100 text-emerald-700 dark:from-emerald-900 dark:to-teal-900 dark:text-emerald-300 text-xs font-bold">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
@@ -517,7 +537,7 @@ export default function AppLayout() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:bg-emerald-100 dark:hover:bg-emerald-900/20" className="h-10 w-10 min-touch hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title={t('theme.toggle')} title={t('theme.toggle')}
> >
@@ -526,7 +546,7 @@ export default function AppLayout() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:bg-emerald-100 dark:hover:bg-emerald-900/20" className="h-10 w-10 min-touch hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
onClick={toggleLocale} onClick={toggleLocale}
title={t('language.toggle')} title={t('language.toggle')}
> >
@@ -535,7 +555,7 @@ export default function AppLayout() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-500 dark:hover:text-red-400" className="h-10 w-10 min-touch hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-500 dark:hover:text-red-400"
onClick={handleLogout} onClick={handleLogout}
title={t('auth.logout')} title={t('auth.logout')}
> >
@@ -547,8 +567,8 @@ export default function AppLayout() {
</Sidebar> </Sidebar>
<SidebarInset> <SidebarInset>
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white/90 to-emerald-50/30 dark:from-gray-950/90 dark:to-emerald-950/10 backdrop-blur-sm px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10"> <header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 backdrop-blur-sm px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10">
<SidebarTrigger className="-ml-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" /> <SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
<Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" /> <Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" />
<Breadcrumb className="min-w-0 flex-1"> <Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="text-sm"> <BreadcrumbList className="text-sm">
@@ -817,14 +837,14 @@ export default function AppLayout() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white/80 to-emerald-50/40 dark:from-gray-950/80 dark:to-emerald-950/10 py-3 px-4"> <footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 py-3 px-4">
<div className="flex items-center justify-between text-xs text-emerald-600/60 dark:text-emerald-400/40"> <div className="flex items-center justify-between text-xs text-emerald-600/60 dark:text-emerald-400/40">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Heart className="h-3 w-3 text-emerald-500" /> <Leaf className="h-3 w-3 text-emerald-500" />
CompetenceTrack — {t('footer.copyright')} CompetenceTrack — {t('footer.copyright')}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="hidden sm:inline">{t('footer.version')} · {t('footer.oss')} · 🇩🇪 🇬🇧</span> <span className="hidden sm:inline">{t('footer.version')} · {t('footer.oss')}</span>
<span className="sm:hidden">{t('footer.version')}</span> <span className="sm:hidden">{t('footer.version')}</span>
</span> </span>
</div> </div>
+21 -18
View File
@@ -2,7 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield } from 'lucide-react'; import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -97,7 +97,7 @@ export default function AuthView() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 md:p-8 relative overflow-hidden">
{/* Animated floating geometric shapes background */} {/* Animated floating geometric shapes background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Large emerald circle */} {/* Large emerald circle */}
@@ -232,7 +232,7 @@ export default function AuthView() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="w-full max-w-md" className="w-full max-w-md md:max-w-lg"
> >
{/* Branding */} {/* Branding */}
<div className="text-center mb-8 lg:hidden"> <div className="text-center mb-8 lg:hidden">
@@ -253,8 +253,8 @@ export default function AuthView() {
</div> </div>
{/* Auth Card */} {/* Auth Card */}
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-xl overflow-hidden"> <Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-2xl overflow-hidden bg-white dark:bg-gray-950">
<CardHeader className="text-center pb-1 pt-6 bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10"> <CardHeader className="text-center pb-2 pt-8 bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10">
<CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100"> <CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100">
{mode === 'login' ? t('auth.login') : t('auth.register_title')} {mode === 'login' ? t('auth.login') : t('auth.register_title')}
</CardTitle> </CardTitle>
@@ -275,12 +275,12 @@ export default function AuthView() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label> <Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" /> <User className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
<Input <Input
id="firstName" id="firstName"
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20" className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base"
placeholder={t('auth.firstName')} placeholder={t('auth.firstName')}
required required
/> />
@@ -292,7 +292,7 @@ export default function AuthView() {
id="lastName" id="lastName"
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
className="border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20" className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base"
placeholder={t('auth.lastName')} placeholder={t('auth.lastName')}
required required
/> />
@@ -305,13 +305,13 @@ export default function AuthView() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label> <Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
<div className="relative"> <div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" /> <Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
<Input <Input
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20" className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base"
placeholder="name@schule.de" placeholder="name@schule.de"
required required
/> />
@@ -332,7 +332,7 @@ export default function AuthView() {
)} )}
</div> </div>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" /> <Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
<Input <Input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -346,11 +346,11 @@ export default function AuthView() {
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors" className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-10 h-10 flex items-center justify-center"
tabIndex={-1} tabIndex={-1}
aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')} aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button> </button>
</div> </div>
</div> </div>
@@ -372,7 +372,7 @@ export default function AuthView() {
<Button <Button
type="submit" type="submit"
className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/30 rounded-xl" className="w-full h-12 min-h-[44px] bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/30 rounded-xl text-base"
disabled={loading || demoLoading} disabled={loading || demoLoading}
> >
{loading || demoLoading ? ( {loading || demoLoading ? (
@@ -405,7 +405,7 @@ export default function AuthView() {
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('auth.demo_section_subtitle')}</p> <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('auth.demo_section_subtitle')}</p>
</div> </div>
<div className="grid grid-cols-2 gap-2.5"> <div className="grid grid-cols-2 gap-3">
{DEMO_ACCOUNTS.map((demo) => ( {DEMO_ACCOUNTS.map((demo) => (
<Button <Button
key={demo.email} key={demo.email}
@@ -413,9 +413,9 @@ export default function AuthView() {
variant="outline" variant="outline"
onClick={() => handleDemoLogin(demo.email, demo.password)} onClick={() => handleDemoLogin(demo.email, demo.password)}
disabled={loading || demoLoading} disabled={loading || demoLoading}
className={`h-auto py-2.5 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl transition-all`} className={`h-auto min-h-[44px] py-3 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl transition-all`}
> >
<demo.icon className="h-4 w-4" /> <demo.icon className="h-5 w-5" />
<span>{t(demo.labelKey)}</span> <span>{t(demo.labelKey)}</span>
</Button> </Button>
))} ))}
@@ -436,9 +436,12 @@ export default function AuthView() {
</Card> </Card>
{/* Footer */} {/* Footer */}
<p className="mt-8 text-center text-xs text-emerald-600/40 dark:text-emerald-400/30"> <div className="mt-8 text-center flex items-center justify-center gap-1.5">
<Leaf className="h-3.5 w-3.5 text-emerald-500/40 dark:text-emerald-400/30" />
<p className="text-xs text-emerald-600/40 dark:text-emerald-400/30">
{t('app.tagline')} {t('app.tagline')}
</p> </p>
</div>
</motion.div> </motion.div>
</div> </div>
); );
+23 -1
View File
@@ -37,7 +37,29 @@ import {
Frown, Frown,
Meh, Meh,
Sparkles, Sparkles,
Handshake,
Hand,
AlertTriangle,
Flame,
Clock,
Star,
MessageSquare,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
Handshake, Hand, AlertTriangle, Flame, Clock, Star, MessageSquare,
Shield, ShieldAlert, Smile, Frown, Meh, Sparkles, Award, CheckCircle2,
Circle, Plus, Pencil, Trash2, Filter, X, CalendarIcon,
MapPin, UserCheck, PieChartIcon, BarChart3, ListChecks, Loader2,
};
function renderLucideIcon(name: string, className?: string): React.ReactNode {
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return null;
return <IconComp className={className ?? 'h-3.5 w-3.5'} />;
}
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -360,7 +382,7 @@ function CategoryBadge({ category }: { category: BehaviorIncident['category'] })
borderColor: hexToRgba(category.color, 0.3), borderColor: hexToRgba(category.color, 0.3),
}} }}
> >
{category.icon && <span>{category.icon}</span>} {category.icon && renderLucideIcon(category.icon)}
{category.name} {category.name}
</span> </span>
); );
+16 -3
View File
@@ -26,7 +26,20 @@ import {
BarChart3, BarChart3,
ListChecks, ListChecks,
MessageSquareText, MessageSquareText,
MessageSquare,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
const LUCIDE_ICON_MAP_CB: Record<string, LucideIcon> = {
MessageSquare, MessageSquareText, BookOpen, Plus, Pencil, Trash2, Copy, Loader2, Search, Eye, Globe, Lock, Tag, BarChart3, ListChecks,
};
function renderCBIcon(name: string, className?: string): React.ReactNode {
const IconComp = LUCIDE_ICON_MAP_CB[name];
if (!IconComp) return null;
return <IconComp className={className ?? 'h-3.5 w-3.5 inline'} />;
}
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -594,7 +607,7 @@ export default function CommentBankView() {
borderColor: hexToRgba(entry.category.color, 0.3), borderColor: hexToRgba(entry.category.color, 0.3),
}} }}
> >
{entry.category.icon ? `${entry.category.icon} ` : ''}{entry.category.name} {entry.category.icon ? renderCBIcon(entry.category.icon) : null}{entry.category.name}
</Badge> </Badge>
{entry.isPublic ? ( {entry.isPublic ? (
<Badge variant="outline" className="text-xs bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800"> <Badge variant="outline" className="text-xs bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800">
@@ -863,7 +876,7 @@ export default function CommentBankView() {
borderColor: hexToRgba(entry.category.color, 0.3), borderColor: hexToRgba(entry.category.color, 0.3),
}} }}
> >
{entry.category.icon ? `${entry.category.icon} ` : ''}{entry.category.name} {entry.category.icon ? renderCBIcon(entry.category.icon) : null}{entry.category.name}
</Badge> </Badge>
<Badge variant="secondary" className="text-xs font-semibold"> <Badge variant="secondary" className="text-xs font-semibold">
{entry.usageCount}× {entry.usageCount}×
@@ -1034,7 +1047,7 @@ export default function CommentBankView() {
borderColor: hexToRgba(entryDetailData.category.color, 0.3), borderColor: hexToRgba(entryDetailData.category.color, 0.3),
}} }}
> >
{entryDetailData.category.icon ? `${entryDetailData.category.icon} ` : ''}{entryDetailData.category.name} {entryDetailData.category.icon ? renderCBIcon(entryDetailData.category.icon) : null}{entryDetailData.category.name}
</Badge> </Badge>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
+37 -8
View File
@@ -392,7 +392,7 @@ export default function DashboardView() {
</motion.div> </motion.div>
{/* ===== STATS CARDS ===== */} {/* ===== STATS CARDS ===== */}
<motion.div variants={itemVariants} className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4"> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[ {[
{ icon: Users, label: t('dashboard.total_students'), value: data.stats.totalStudents, color: 'emerald', trend: 'up', tip: t('dashboard.total_students'), spark: [3, 5, 4, 6, 7, 8, data.stats.totalStudents] }, { icon: Users, label: t('dashboard.total_students'), value: data.stats.totalStudents, color: 'emerald', trend: 'up', tip: t('dashboard.total_students'), spark: [3, 5, 4, 6, 7, 8, data.stats.totalStudents] },
{ icon: BarChart3, label: t('dashboard.total_classes'), value: data.stats.totalClasses, color: 'teal', trend: 'up', tip: t('dashboard.total_classes'), spark: [1, 2, 2, 3, 3, 4, data.stats.totalClasses] }, { icon: BarChart3, label: t('dashboard.total_classes'), value: data.stats.totalClasses, color: 'teal', trend: 'up', tip: t('dashboard.total_classes'), spark: [1, 2, 2, 3, 3, 4, data.stats.totalClasses] },
@@ -659,7 +659,7 @@ export default function DashboardView() {
<motion.div whileHover={{ scale: 1.02, y: -1 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.15 }}> <motion.div whileHover={{ scale: 1.02, y: -1 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.15 }}>
<Button <Button
onClick={() => setCurrentView('progress')} onClick={() => setCurrentView('progress')}
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 rounded-xl px-6 h-11 transition-shadow hover:shadow-xl hover:shadow-emerald-300/40" className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 rounded-xl px-6 h-12 min-h-[44px] transition-shadow hover:shadow-xl hover:shadow-emerald-300/40"
> >
<motion.div <motion.div
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -676,7 +676,7 @@ export default function DashboardView() {
<Button <Button
onClick={() => setCurrentView('assessments')} onClick={() => setCurrentView('assessments')}
variant="outline" variant="outline"
className="border-emerald-300 dark:border-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 rounded-xl px-6 h-11 transition-shadow hover:shadow-md hover:shadow-emerald-100/40" className="border-emerald-300 dark:border-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 rounded-xl px-6 h-12 min-h-[44px] transition-shadow hover:shadow-md hover:shadow-emerald-100/40"
> >
<ClipboardCheck className="h-5 w-5 mr-2" /> <ClipboardCheck className="h-5 w-5 mr-2" />
{t('action.create_assessment')} {t('action.create_assessment')}
@@ -686,7 +686,7 @@ export default function DashboardView() {
<Button <Button
onClick={() => setCurrentView('reports')} onClick={() => setCurrentView('reports')}
variant="outline" variant="outline"
className="border-teal-300 dark:border-teal-700 hover:bg-teal-50 dark:hover:bg-teal-900/20 text-teal-700 dark:text-teal-300 rounded-xl px-6 h-11 transition-shadow hover:shadow-md hover:shadow-teal-100/40" className="border-teal-300 dark:border-teal-700 hover:bg-teal-50 dark:hover:bg-teal-900/20 text-teal-700 dark:text-teal-300 rounded-xl px-6 h-12 min-h-[44px] transition-shadow hover:shadow-md hover:shadow-teal-100/40"
> >
<FileText className="h-5 w-5 mr-2" /> <FileText className="h-5 w-5 mr-2" />
{t('action.generate_report')} {t('action.generate_report')}
@@ -720,7 +720,7 @@ export default function DashboardView() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[ {[
{ {
icon: ClipboardCheck, icon: ClipboardCheck,
@@ -781,14 +781,14 @@ export default function DashboardView() {
whileHover={{ scale: 1.03, y: -3 }} whileHover={{ scale: 1.03, y: -3 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
transition={{ duration: 0.18, ease: 'easeOut' }} transition={{ duration: 0.18, ease: 'easeOut' }}
className={`group text-left p-4 rounded-xl bg-gradient-to-br ${action.tile} border ${action.border} hover:shadow-lg ${action.hoverGlow} transition-shadow duration-300 relative overflow-hidden`} className={`group text-left p-4 min-h-[80px] rounded-xl bg-gradient-to-br ${action.tile} border ${action.border} hover:shadow-lg ${action.hoverGlow} transition-shadow duration-300 relative overflow-hidden`}
> >
{/* Subtle glass overlay on hover */} {/* Subtle glass overlay on hover */}
<div className="absolute inset-0 bg-white/0 group-hover:bg-white/20 dark:group-hover:bg-white/5 transition-colors duration-300 pointer-events-none rounded-xl" /> <div className="absolute inset-0 bg-white/0 group-hover:bg-white/20 dark:group-hover:bg-white/5 transition-colors duration-300 pointer-events-none rounded-xl" />
<div className="relative"> <div className="relative">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${action.iconBg} ${action.iconText} shadow-sm transition-transform duration-200 group-hover:scale-110 group-hover:rotate-3`}> <div className={`flex items-center justify-center w-12 h-12 rounded-xl ${action.iconBg} ${action.iconText} shadow-sm transition-transform duration-200 group-hover:scale-110 group-hover:rotate-3`}>
<action.icon className="h-5 w-5" /> <action.icon className="h-6 w-6" />
</div> </div>
{action.recentlyUsed && ( {action.recentlyUsed && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[9px] font-semibold text-emerald-600 dark:text-emerald-400"> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[9px] font-semibold text-emerald-600 dark:text-emerald-400">
@@ -1144,6 +1144,35 @@ export default function DashboardView() {
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
{/* ===== ENVIRONMENTAL TIPS SECTION ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shrink-0">
<Leaf className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
{t('dashboard.env_tip_title')}
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{t('dashboard.env_tip_text')}
</p>
</div>
<div className="hidden md:flex items-center gap-2 shrink-0">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<TreePine className="h-4 w-4" />
</div>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<Sprout className="h-4 w-4" />
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div> </motion.div>
); );
} }
File diff suppressed because it is too large. Load diff
+516
View File
@@ -0,0 +1,516 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Plus,
Pencil,
Trash2,
Share2,
Leaf,
Palette,
Search,
Filter,
Image as ImageIcon,
Eye,
EyeOff,
Loader2,
BookOpen,
Save,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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';
import DrawingCanvas, { type Stroke } from '@/components/drawing-canvas';
/* ── Types ─────────────────────────────────────────────────────────── */
interface DrawingRecord {
id: string;
title: string;
description?: string;
drawingData: string;
imageData?: string;
subjectId?: string;
classGroupId?: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
interface SubjectOption {
id: string;
name: string;
}
/* ── Animation variants ────────────────────────────────────────────── */
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.06 } },
};
const itemVariants = {
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
};
/* ── Main Component ────────────────────────────────────────────────── */
export default function DrawingView() {
const { currentUser } = useAppStore();
// View state
const [isCanvasOpen, setIsCanvasOpen] = useState(false);
const [editingDrawing, setEditingDrawing] = useState<DrawingRecord | null>(null);
// Data state
const [drawings, setDrawings] = useState<DrawingRecord[]>([]);
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Filter state
const [filterSubject, setFilterSubject] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
// Delete state
const [deleteTarget, setDeleteTarget] = useState<DrawingRecord | null>(null);
/* ── Fetch drawings ─────────────────────────────────────────────── */
const fetchDrawings = useCallback(async () => {
try {
setIsLoading(true);
const data = await apiGet<DrawingRecord[]>('/api/drawings');
setDrawings(Array.isArray(data) ? data : []);
} catch {
setDrawings([]);
} finally {
setIsLoading(false);
}
}, []);
const fetchSubjects = useCallback(async () => {
try {
const data = await apiGet<SubjectOption[]>('/api/subjects');
setSubjects(Array.isArray(data) ? data : []);
} catch {
setSubjects([]);
}
}, []);
useEffect(() => {
fetchDrawings();
fetchSubjects();
}, [fetchDrawings, fetchSubjects]);
/* ── Filtered drawings ──────────────────────────────────────────── */
const filteredDrawings = drawings.filter((d) => {
if (filterSubject !== 'all' && d.subjectId !== filterSubject) return false;
if (searchQuery && !d.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
/* ── Save handler ───────────────────────────────────────────────── */
const handleSaveDrawing = useCallback(
async (drawingData: string, imageData: string) => {
try {
if (editingDrawing) {
// Update existing
await apiPut(`/api/drawings/${editingDrawing.id}`, {
title: editingDrawing.title,
drawingData,
imageData,
});
} else {
// Create new
await apiPost('/api/drawings', {
title: t('drawing.untitled'),
drawingData,
imageData,
schoolId: currentUser?.schoolId,
subjectId: filterSubject !== 'all' ? filterSubject : undefined,
});
}
setIsCanvasOpen(false);
setEditingDrawing(null);
fetchDrawings();
} catch {
// Error handling
}
},
[editingDrawing, currentUser, filterSubject, fetchDrawings]
);
/* ── Delete handler ─────────────────────────────────────────────── */
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await apiDelete(`/api/drawings/${deleteTarget.id}`);
setDeleteTarget(null);
fetchDrawings();
} catch {
// Error handling
}
}, [deleteTarget, fetchDrawings]);
/* ── Toggle public ──────────────────────────────────────────────── */
const handleTogglePublic = useCallback(
async (drawing: DrawingRecord) => {
try {
await apiPut(`/api/drawings/${drawing.id}`, {
isPublic: !drawing.isPublic,
});
fetchDrawings();
} catch {
// Error handling
}
},
[fetchDrawings]
);
/* ── Open canvas for new drawing ────────────────────────────────── */
const handleNewDrawing = useCallback(() => {
setEditingDrawing(null);
setIsCanvasOpen(true);
}, []);
/* ── Open canvas for editing ────────────────────────────────────── */
const handleEditDrawing = useCallback((drawing: DrawingRecord) => {
setEditingDrawing(drawing);
setIsCanvasOpen(true);
}, []);
/* ── Exit canvas ────────────────────────────────────────────────── */
const handleExitCanvas = useCallback(() => {
setIsCanvasOpen(false);
setEditingDrawing(null);
}, []);
/* ── Canvas mode ────────────────────────────────────────────────── */
if (isCanvasOpen) {
return (
<div className="h-screen w-full">
<DrawingCanvas
initialDrawingData={editingDrawing?.drawingData}
onSave={handleSaveDrawing}
onExit={handleExitCanvas}
title={editingDrawing?.title}
subjectId={editingDrawing?.subjectId}
classGroupId={editingDrawing?.classGroupId}
/>
</div>
);
}
/* ── Gallery mode ───────────────────────────────────────────────── */
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<motion.div
className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* ── Header ──────────────────────────────────────────────── */}
<motion.div variants={itemVariants} className="mb-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-gray-100">
<Palette className="h-7 w-7 text-emerald-600" />
{t('drawing.title')}
</h1>
<p className="mt-1 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<Leaf className="h-4 w-4 text-emerald-500" />
{t('drawing.subtitle')}
</p>
</div>
<Button
onClick={handleNewDrawing}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Plus className="mr-2 h-4 w-4" />
{t('drawing.new')}
</Button>
</div>
</motion.div>
{/* ── Eco tip banner ──────────────────────────────────────── */}
<motion.div variants={itemVariants} className="mb-6">
<Card className="border-emerald-200 bg-gradient-to-r from-emerald-50 to-green-50 dark:border-emerald-800 dark:from-emerald-950/30 dark:to-green-950/30">
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/50">
<Leaf className="h-5 w-5 text-emerald-600" />
</div>
<p className="text-sm text-emerald-700 dark:text-emerald-300">
{t('drawing.eco_tip')}
</p>
</CardContent>
</Card>
</motion.div>
{/* ── Filters ─────────────────────────────────────────────── */}
<motion.div variants={itemVariants} className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('action.search')}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<Select value={filterSubject} onValueChange={setFilterSubject}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t('drawing.filter_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('drawing.all_subjects')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</motion.div>
{/* ── Drawing Grid ────────────────────────────────────────── */}
{isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i} className="overflow-hidden">
<Skeleton className="aspect-video w-full" />
<CardContent className="p-4">
<Skeleton className="mb-2 h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : filteredDrawings.length === 0 ? (
<motion.div variants={itemVariants}>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
<ImageIcon className="h-8 w-8 text-gray-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t('drawing.no_drawings')}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('drawing.no_drawings_desc')}
</p>
</div>
<Button
onClick={handleNewDrawing}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Plus className="mr-2 h-4 w-4" />
{t('drawing.new')}
</Button>
</CardContent>
</Card>
</motion.div>
) : (
<motion.div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
variants={containerVariants}
>
<AnimatePresence>
{filteredDrawings.map((drawing) => (
<motion.div
key={drawing.id}
variants={itemVariants}
layout
exit={{ opacity: 0, scale: 0.95 }}
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<Card className="group overflow-hidden transition-shadow hover:shadow-lg">
{/* Thumbnail */}
<div className="relative aspect-video bg-white">
{drawing.imageData ? (
<img
src={drawing.imageData}
alt={drawing.title}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<Palette className="h-10 w-10 text-gray-300" />
</div>
)}
{/* Public badge */}
{drawing.isPublic && (
<Badge className="absolute right-2 top-2 bg-emerald-100 text-emerald-700">
<Share2 className="mr-1 h-3 w-3" />
{t('drawing.public')}
</Badge>
)}
{/* Hover overlay */}
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => handleEditDrawing(drawing)}
className="bg-white text-gray-900 hover:bg-gray-100"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('drawing.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => handleTogglePublic(drawing)}
className="bg-white text-gray-900 hover:bg-gray-100"
>
{drawing.isPublic ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{drawing.isPublic ? t('drawing.private') : t('drawing.public_desc')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteTarget(drawing)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('drawing.delete')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Info */}
<CardContent className="p-3">
<h3 className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{drawing.title || t('drawing.untitled')}
</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{new Date(drawing.updatedAt).toLocaleDateString()}</span>
{drawing.isPublic && (
<Badge variant="outline" className="text-[10px]">
{t('drawing.public')}
</Badge>
)}
</div>
{drawing.description && (
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
{drawing.description}
</p>
)}
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
{/* ── Footer eco message ──────────────────────────────────── */}
<motion.div variants={itemVariants} className="mt-8">
<Card className="border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/20">
<CardContent className="flex items-center gap-3 py-3">
<Leaf className="h-5 w-5 text-emerald-600" />
<p className="text-sm text-emerald-700 dark:text-emerald-300">
{t('drawing.eco_message')}
</p>
</CardContent>
</Card>
</motion.div>
</motion.div>
{/* ── Delete Confirmation Dialog ─────────────────────────────── */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('drawing.delete_confirm')}</AlertDialogTitle>
<AlertDialogDescription>
{t('drawing.delete_desc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
{t('drawing.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1299,7 +1299,7 @@ function StatisticsPanel({
<div key={c} className="space-y-1"> <div key={c} className="space-y-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1.5"> <span className="font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
<span>{cfg.emoji}</span> {(() => { const CatIcon = cfg.iconComponent; return <CatIcon className="w-3.5 h-3.5" />; })()}
{t(CATEGORY_LABEL_KEY[c])} {t(CATEGORY_LABEL_KEY[c])}
</span> </span>
<span className="text-gray-500 dark:text-gray-400">{count}</span> <span className="text-gray-500 dark:text-gray-400">{count}</span>
+60 -5
View File
@@ -32,6 +32,8 @@ import {
BarChart3, BarChart3,
TrendingUp, TrendingUp,
CheckCircle, CheckCircle,
Leaf,
DatabaseIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -650,7 +652,7 @@ export default function SettingsView() {
<span className="hidden sm:inline">{t('settings.tab_data')}</span> <span className="hidden sm:inline">{t('settings.tab_data')}</span>
</TabsTrigger> </TabsTrigger>
{isAdmin && ( {isAdmin && (
<TabsTrigger value="demo" className="rounded-lg data-[state=active]:bg-amber-500 data-[state=active]:text-white"> <TabsTrigger value="demo" className="rounded-lg min-h-[44px] data-[state=active]:bg-amber-500 data-[state=active]:text-white">
<Zap className="h-4 w-4 mr-1.5" /> <Zap className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('settings.demo_accounts')}</span> <span className="hidden sm:inline">{t('settings.demo_accounts')}</span>
</TabsTrigger> </TabsTrigger>
@@ -1541,7 +1543,60 @@ export default function SettingsView() {
{/* ── Demo Accounts Tab ───────────────────────────────── */} {/* ── Demo Accounts Tab ───────────────────────────────── */}
{isAdmin && ( {isAdmin && (
<TabsContent value="demo"> <TabsContent value="demo">
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden"> {/* Demo Data Danger Zone Card */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="border-2 border-rose-200 dark:border-rose-900/40 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 mb-6">
<CardHeader className="bg-gradient-to-r from-rose-50/80 to-rose-100/40 dark:from-rose-900/20 dark:to-rose-900/10 pb-3 pt-6">
<CardTitle className="flex items-center gap-2 text-rose-700 dark:text-rose-300">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<Trash2 className="h-5 w-5" />
</div>
{t('settings.demo_data_title')}
</CardTitle>
<CardDescription className="text-rose-600/60 dark:text-rose-400/40">{t('settings.demo_data_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Demo record count */}
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-rose-50/60 to-rose-50/0 dark:from-rose-900/15 dark:to-rose-900/0 border border-rose-100/50 dark:border-rose-900/20">
<DatabaseIcon className="h-5 w-5 text-rose-500 shrink-0" />
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{t('settings.demo_record_count')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{demoAccounts.length} {t('settings.demo_record_count').toLowerCase()}
</p>
</div>
</div>
{/* Big red Delete All Demo Data button */}
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
onClick={() => setDeleteAllDemoOpen(true)}
className="w-full h-12 min-h-[44px] bg-gradient-to-r from-rose-500 to-rose-600 hover:from-rose-600 hover:to-rose-700 text-white shadow-lg shadow-rose-300/20 rounded-xl"
>
<Trash2 className="h-5 w-5 mr-2" />
{t('settings.demo_delete_all_data')}
</Button>
</motion.div>
{/* Environmental-friendly messaging */}
<div className="flex items-center gap-2 p-3 rounded-xl bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/15 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
<Leaf className="h-4 w-4 text-emerald-500 shrink-0" />
<p className="text-xs text-emerald-700/70 dark:text-emerald-400/50">
{t('settings.env_tip')}
</p>
</div>
</CardContent>
</Card>
</motion.div>
{/* Demo Accounts Card */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden bg-white dark:bg-gray-950">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-emerald-50/30 dark:from-amber-900/10 dark:to-emerald-900/5"> <CardHeader className="bg-gradient-to-r from-amber-50/50 to-emerald-50/30 dark:from-amber-900/10 dark:to-emerald-900/5">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
@@ -1565,7 +1620,7 @@ export default function SettingsView() {
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}> <motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button <Button
onClick={handleEnableAllDemo} onClick={handleEnableAllDemo}
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl min-h-[44px]"
> >
<CheckCircle2 className="h-4 w-4 mr-1.5" /> <CheckCircle2 className="h-4 w-4 mr-1.5" />
{t('settings.demo_enable')} {t('settings.demo_enable')}
@@ -1574,7 +1629,7 @@ export default function SettingsView() {
<Button <Button
onClick={handleDisableAllDemo} onClick={handleDisableAllDemo}
variant="outline" variant="outline"
className="border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl" className="border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl min-h-[44px]"
> >
<Clock className="h-4 w-4 mr-1.5" /> <Clock className="h-4 w-4 mr-1.5" />
{t('settings.demo_disable_all')} {t('settings.demo_disable_all')}
@@ -1582,7 +1637,7 @@ export default function SettingsView() {
<Button <Button
onClick={() => setDeleteAllDemoOpen(true)} onClick={() => setDeleteAllDemoOpen(true)}
variant="outline" variant="outline"
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl" className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl min-h-[44px]"
> >
<Trash2 className="h-4 w-4 mr-1.5" /> <Trash2 className="h-4 w-4 mr-1.5" />
{t('settings.demo_delete_all')} {t('settings.demo_delete_all')}
+344
View File
@@ -480,6 +480,16 @@ const de: TranslationDict = {
'footer.copyright': 'CompetenceTrack — Freie Schulsoftware', 'footer.copyright': 'CompetenceTrack — Freie Schulsoftware',
'footer.version': 'Version 1.0', 'footer.version': 'Version 1.0',
'footer.oss': 'Open Source', 'footer.oss': 'Open Source',
'sidebar.eco_message': 'Papier sparen, Umwelt schutzen',
'dashboard.env_tip_title': 'Umwelttip',
'dashboard.env_tip_text': 'Digitale Kompetenznachweise sparen Papier und schutzen die Umwelt.',
'settings.demo_data_title': 'Demo-Daten',
'settings.demo_data_desc': 'Demo-Daten werden automatisch erstellt und können gelöscht werden.',
'settings.demo_record_count': 'Datensatze',
'settings.demo_delete_all_data': 'Alle Demo-Daten löschen',
'settings.demo_delete_confirm_data': 'Wirklich alle Demo-Daten (Kompetenznachweise, Bewertungen, Berichte) löschen? Die Demo-Konten bleiben bestehen.',
'settings.demo_data_deleted': 'Demo-Daten gelöscht',
'settings.env_tip': 'Papier sparen, Umwelt schutzen — digitale Nachweise statt Druck.',
// New styling keys // New styling keys
'dashboard.start_here': 'Jetzt beginnen!', 'dashboard.start_here': 'Jetzt beginnen!',
@@ -1517,6 +1527,7 @@ const de: TranslationDict = {
// ── Comments Bank (Zeugnisbemerkungen) ────────────────────────────── // ── Comments Bank (Zeugnisbemerkungen) ──────────────────────────────
'nav.comments': 'Bemerkungen', 'nav.comments': 'Bemerkungen',
'nav.drawing': 'Zeichnen',
'comments.title': 'Zeugnisbemerkungen', 'comments.title': 'Zeugnisbemerkungen',
'comments.subtitle': 'Bibliothek wiederverwendbarer Bemerkungstexte für Zeugnisse', 'comments.subtitle': 'Bibliothek wiederverwendbarer Bemerkungstexte für Zeugnisse',
'comments.tab_entries': 'Bemerkungen', 'comments.tab_entries': 'Bemerkungen',
@@ -1690,6 +1701,167 @@ const de: TranslationDict = {
'shortcuts.recent_actions': 'Letzte Aktionen', 'shortcuts.recent_actions': 'Letzte Aktionen',
'shortcuts.views': 'Ansichten', 'shortcuts.views': 'Ansichten',
'shortcuts.quick_actions': 'Schnellaktionen', 'shortcuts.quick_actions': 'Schnellaktionen',
// Drawing
'drawing.title': 'Zeichnungen',
'drawing.subtitle': 'Digital zeichnen statt Papier verschwenden',
'drawing.new': 'Neue Zeichnung',
'drawing.gallery': 'Galerie',
'drawing.edit': 'Bearbeiten',
'drawing.delete': 'Löschen',
'drawing.delete_confirm': 'Zeichnung wirklich löschen?',
'drawing.delete_desc': 'Diese Aktion kann nicht rückgängig gemacht werden.',
'drawing.save': 'Speichern',
'drawing.save_success': 'Zeichnung gespeichert',
'drawing.export_png': 'Als PNG exportieren',
'drawing.auto_save': 'Automatisch gespeichert',
'drawing.no_drawings': 'Keine Zeichnungen vorhanden',
'drawing.no_drawings_desc': 'Erstellen Sie Ihre erste digitale Zeichnung.',
'drawing.title_label': 'Titel',
'drawing.description_label': 'Beschreibung',
'drawing.subject_label': 'Fach',
'drawing.class_label': 'Klasse',
'drawing.public': 'Öffentlich teilen',
'drawing.public_desc': 'Mit der Klasse teilen',
'drawing.private': 'Privat',
'drawing.canvas': 'Zeichenfläche',
'drawing.exit': 'Zeichnung verlassen',
'drawing.clear': 'Leeren',
'drawing.clear_confirm': 'Zeichenfläche wirklich leeren?',
'drawing.undo': 'Rückgängig',
'drawing.redo': 'Wiederholen',
'drawing.tool_pencil': 'Stift',
'drawing.tool_pen': 'Füller',
'drawing.tool_line': 'Linie',
'drawing.tool_rectangle': 'Rechteck',
'drawing.tool_circle': 'Kreis',
'drawing.tool_eraser': 'Radiergummi',
'drawing.color': 'Farbe',
'drawing.stroke_width': 'Strichstärke',
'drawing.background': 'Hintergrund',
'drawing.bg_blank': 'Weiss',
'drawing.bg_lined': 'Liniert',
'drawing.bg_grid': 'Kariert',
'drawing.bg_dotted': 'Gepunktet',
'drawing.guide_mode': 'Hilfslinien',
'drawing.guide_shapes': 'Formvorlagen',
'drawing.guide_off': 'Aus',
'drawing.guide_basic': 'Grundformen',
'drawing.guide_circles': 'Kreise',
'drawing.guide_perspective': 'Perspektive',
'drawing.pressure': 'Druckempfindlichkeit',
'drawing.loading': 'Laden...',
'drawing.saving': 'Speichern...',
'drawing.filter_subject': 'Nach Fach filtern',
'drawing.all_subjects': 'Alle Fächer',
'drawing.created_at': 'Erstellt',
'drawing.updated_at': 'Aktualisiert',
'drawing.untitled': 'Unbenannte Zeichnung',
'drawing.eco_message': 'Digital zeichnen statt Papier verschwenden',
'drawing.eco_tip': 'Jede digitale Zeichnung spart Papier und schont die Umwelt',
// ─── Digital Notebooks ──────────────────────────────────────────────
'nav.notebooks': 'Hefte',
'notebooks.title': 'Digitale Hefte',
'notebooks.subtitle': 'Papier sparen, Umwelt schützen — digitale Hefte statt Papierhefte',
'notebooks.create': 'Neues Heft erstellen',
'notebooks.create_title': 'Neues digitales Heft erstellen',
'notebooks.create_desc': 'Erstellen Sie ein digitales Heft und sparen Sie Papier.',
'notebooks.edit_title': 'Heft bearbeiten',
'notebooks.library': 'Hefte-Bibliothek',
'notebooks.archived': 'Archivierte Hefte',
'notebooks.show_archived': 'Archivierte anzeigen',
'notebooks.hide_archived': 'Archivierte ausblenden',
'notebooks.archive': 'Archivieren',
'notebooks.unarchive': 'Wiederherstellen',
'notebooks.delete': 'Löschen',
'notebooks.delete_confirm': 'Dieses Heft wirklich löschen? Alle Seiten gehen verloren.',
'notebooks.no_notebooks': 'Keine Hefte vorhanden',
'notebooks.no_notebooks_desc': 'Erstellen Sie Ihr erstes digitales Heft.',
'notebooks.no_notebooks_create': 'Erstes Heft erstellen',
'notebooks.no_archived': 'Keine archivierten Hefte',
'notebooks.field_title': 'Titel',
'notebooks.field_title_placeholder': 'Hefttitel eingeben',
'notebooks.field_description': 'Beschreibung',
'notebooks.field_description_placeholder': 'Optionale Beschreibung des Hefts',
'notebooks.field_type': 'Hefttyp',
'notebooks.field_subject': 'Fach',
'notebooks.field_class': 'Klasse',
'notebooks.field_color': 'Farbe',
'notebooks.field_icon': 'Symbol',
'notebooks.field_public': 'Mit Klasse teilen',
'notebooks.field_public_hint': 'Andere können dieses Heft einsehen',
'notebooks.type_lined': 'Liniert',
'notebooks.type_grid': 'Kariert',
'notebooks.type_blank': 'Blanko',
'notebooks.type_dotted': 'Punktiert',
'notebooks.type_music': 'Musik',
'notebooks.type_calligraphy': 'Kalligraphie',
'notebooks.type_lined_desc': 'Liniertes Heft für Text',
'notebooks.type_grid_desc': 'Kariertes Heft für Mathematik',
'notebooks.type_blank_desc': 'Blanko-Heft für freies Gestalten',
'notebooks.type_dotted_desc': 'Punktiertes Heft für Notizen',
'notebooks.type_music_desc': 'Musikheft mit Notenlinien',
'notebooks.type_calligraphy_desc': 'Kalligraphie-Heft zum Schreiben üben',
'notebooks.no_subject': 'Ohne Fach',
'notebooks.no_class': 'Ohne Klasse',
'notebooks.all_subjects': 'Alle Fächer',
'notebooks.page_count': '{count} Seiten',
'notebooks.page': 'Seite',
'notebooks.pages': 'Seiten',
'notebooks.new_page': 'Neue Seite',
'notebooks.add_page': 'Seite hinzufügen',
'notebooks.delete_page': 'Seite löschen',
'notebooks.delete_page_confirm': 'Diese Seite wirklich löschen?',
'notebooks.page_title': 'Seitentitel',
'notebooks.page_title_placeholder': 'Seitenname eingeben',
'notebooks.page_content': 'Seiteninhalt',
'notebooks.bookmark': 'Lesezeichen',
'notebooks.bookmark_add': 'Lesezeichen setzen',
'notebooks.bookmark_remove': 'Lesezeichen entfernen',
'notebooks.drawing_mode': 'Zeichnungsmodus',
'notebooks.drawing_mode_toggle': 'Zeichnen',
'notebooks.drawing_mode_exit': 'Zurück zum Text',
'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert',
'notebooks.back_to_library': 'Zurück zur Bibliothek',
'notebooks.open_notebook': 'Heft öffnen',
'notebooks.created': 'Heft erstellt',
'notebooks.updated': 'Heft aktualisiert',
'notebooks.archived_toast': 'Heft archiviert',
'notebooks.unarchived_toast': 'Heft wiederhergestellt',
'notebooks.deleted_toast': 'Heft gelöscht',
'notebooks.page_saved': 'Seite gespeichert',
'notebooks.page_added': 'Seite hinzugefügt',
'notebooks.page_deleted': 'Seite gelöscht',
'notebooks.error_load': 'Fehler beim Laden der Hefte',
'notebooks.error_save': 'Fehler beim Speichern',
'notebooks.error_create': 'Fehler beim Erstellen des Hefts',
'notebooks.loading': 'Hefte werden geladen…',
'notebooks.public': 'Geteilt',
'notebooks.private': 'Privat',
'notebooks.filter_all': 'Alle',
'notebooks.filter_subject': 'Nach Fach filtern',
'notebooks.eco_message': 'Papier sparen, Umwelt schützen',
'notebooks.eco_tip': '1 Heft = ca. 50 Seiten Papier gespart',
'notebooks.eco_total': '{count} digitale Hefte = ca. {pages} Seiten Papier gespart',
'notebooks.search_placeholder': 'Hefte durchsuchen…',
'notebooks.detail_title': 'Heft-Details',
'notebooks.page_nav': 'Seitennavigation',
'notebooks.content_area': 'Inhaltsbereich',
'notebooks.save_page': 'Seite speichern',
'notebooks.color_emerald': 'Emerald',
'notebooks.color_blue': 'Blau',
'notebooks.color_red': 'Rot',
'notebooks.color_yellow': 'Gelb',
'notebooks.color_purple': 'Lila',
'notebooks.color_orange': 'Orange',
'notebooks.color_teal': 'Teal',
'notebooks.color_pink': 'Pink',
'notebooks.title_required': 'Bitte geben Sie einen Titel ein',
'notebooks.total_notebooks': 'Hefte gesamt',
'notebooks.total_pages': 'Seiten gesamt',
'notebooks.paper_saved': 'Papier gespart',
'notebooks.pages_unit': 'Seiten',
}; };
const en: TranslationDict = { const en: TranslationDict = {
@@ -2169,6 +2341,16 @@ const en: TranslationDict = {
'footer.copyright': 'CompetenceTrack — Free School Software', 'footer.copyright': 'CompetenceTrack — Free School Software',
'footer.version': 'Version 1.0', 'footer.version': 'Version 1.0',
'footer.oss': 'Open Source', 'footer.oss': 'Open Source',
'sidebar.eco_message': 'Save paper, protect the environment',
'dashboard.env_tip_title': 'Eco Tip',
'dashboard.env_tip_text': 'Digital competency records save paper and protect the environment.',
'settings.demo_data_title': 'Demo Data',
'settings.demo_data_desc': 'Demo data is auto-generated and can be deleted at any time.',
'settings.demo_record_count': 'Records',
'settings.demo_delete_all_data': 'Delete All Demo Data',
'settings.demo_delete_confirm_data': 'Really delete all demo data (progress entries, assessments, reports)? Demo accounts will remain.',
'settings.demo_data_deleted': 'Demo data deleted',
'settings.env_tip': 'Save paper, protect the environment — digital records instead of printing.',
// New styling keys // New styling keys
'dashboard.start_here': 'Start here!', 'dashboard.start_here': 'Start here!',
@@ -3206,6 +3388,7 @@ const en: TranslationDict = {
// ── Comments Bank (Report Card Comments) ────────────────────────────── // ── Comments Bank (Report Card Comments) ──────────────────────────────
'nav.comments': 'Comments', 'nav.comments': 'Comments',
'nav.drawing': 'Drawing',
'comments.title': 'Report Card Comments', 'comments.title': 'Report Card Comments',
'comments.subtitle': 'Library of reusable comment texts for report cards', 'comments.subtitle': 'Library of reusable comment texts for report cards',
'comments.tab_entries': 'Comments', 'comments.tab_entries': 'Comments',
@@ -3379,6 +3562,167 @@ const en: TranslationDict = {
'shortcuts.recent_actions': 'Recent Actions', 'shortcuts.recent_actions': 'Recent Actions',
'shortcuts.views': 'Views', 'shortcuts.views': 'Views',
'shortcuts.quick_actions': 'Quick Actions', 'shortcuts.quick_actions': 'Quick Actions',
// Drawing
'drawing.title': 'Drawings',
'drawing.subtitle': 'Draw digitally instead of wasting paper',
'drawing.new': 'New Drawing',
'drawing.gallery': 'Gallery',
'drawing.edit': 'Edit',
'drawing.delete': 'Delete',
'drawing.delete_confirm': 'Really delete this drawing?',
'drawing.delete_desc': 'This action cannot be undone.',
'drawing.save': 'Save',
'drawing.save_success': 'Drawing saved',
'drawing.export_png': 'Export as PNG',
'drawing.auto_save': 'Auto-saved',
'drawing.no_drawings': 'No drawings yet',
'drawing.no_drawings_desc': 'Create your first digital drawing.',
'drawing.title_label': 'Title',
'drawing.description_label': 'Description',
'drawing.subject_label': 'Subject',
'drawing.class_label': 'Class',
'drawing.public': 'Share publicly',
'drawing.public_desc': 'Share with class',
'drawing.private': 'Private',
'drawing.canvas': 'Canvas',
'drawing.exit': 'Exit drawing',
'drawing.clear': 'Clear',
'drawing.clear_confirm': 'Really clear the canvas?',
'drawing.undo': 'Undo',
'drawing.redo': 'Redo',
'drawing.tool_pencil': 'Pencil',
'drawing.tool_pen': 'Pen',
'drawing.tool_line': 'Line',
'drawing.tool_rectangle': 'Rectangle',
'drawing.tool_circle': 'Circle',
'drawing.tool_eraser': 'Eraser',
'drawing.color': 'Color',
'drawing.stroke_width': 'Stroke Width',
'drawing.background': 'Background',
'drawing.bg_blank': 'Blank',
'drawing.bg_lined': 'Lined',
'drawing.bg_grid': 'Grid',
'drawing.bg_dotted': 'Dotted',
'drawing.guide_mode': 'Guide Mode',
'drawing.guide_shapes': 'Shape Templates',
'drawing.guide_off': 'Off',
'drawing.guide_basic': 'Basic Shapes',
'drawing.guide_circles': 'Circles',
'drawing.guide_perspective': 'Perspective',
'drawing.pressure': 'Pressure Sensitivity',
'drawing.loading': 'Loading...',
'drawing.saving': 'Saving...',
'drawing.filter_subject': 'Filter by subject',
'drawing.all_subjects': 'All subjects',
'drawing.created_at': 'Created',
'drawing.updated_at': 'Updated',
'drawing.untitled': 'Untitled Drawing',
'drawing.eco_message': 'Draw digitally instead of wasting paper',
'drawing.eco_tip': 'Every digital drawing saves paper and protects the environment',
// ─── Digital Notebooks ──────────────────────────────────────────────
'nav.notebooks': 'Notebooks',
'notebooks.title': 'Digital Notebooks',
'notebooks.subtitle': 'Save paper, protect the environment — digital notebooks instead of paper',
'notebooks.create': 'Create New Notebook',
'notebooks.create_title': 'Create New Digital Notebook',
'notebooks.create_desc': 'Create a digital notebook and save paper.',
'notebooks.edit_title': 'Edit Notebook',
'notebooks.library': 'Notebook Library',
'notebooks.archived': 'Archived Notebooks',
'notebooks.show_archived': 'Show Archived',
'notebooks.hide_archived': 'Hide Archived',
'notebooks.archive': 'Archive',
'notebooks.unarchive': 'Restore',
'notebooks.delete': 'Delete',
'notebooks.delete_confirm': 'Really delete this notebook? All pages will be lost.',
'notebooks.no_notebooks': 'No notebooks yet',
'notebooks.no_notebooks_desc': 'Create your first digital notebook.',
'notebooks.no_notebooks_create': 'Create First Notebook',
'notebooks.no_archived': 'No archived notebooks',
'notebooks.field_title': 'Title',
'notebooks.field_title_placeholder': 'Enter notebook title',
'notebooks.field_description': 'Description',
'notebooks.field_description_placeholder': 'Optional notebook description',
'notebooks.field_type': 'Notebook Type',
'notebooks.field_subject': 'Subject',
'notebooks.field_class': 'Class',
'notebooks.field_color': 'Color',
'notebooks.field_icon': 'Icon',
'notebooks.field_public': 'Share with class',
'notebooks.field_public_hint': 'Others can view this notebook',
'notebooks.type_lined': 'Lined',
'notebooks.type_grid': 'Grid/Checkered',
'notebooks.type_blank': 'Blank',
'notebooks.type_dotted': 'Dotted',
'notebooks.type_music': 'Music',
'notebooks.type_calligraphy': 'Calligraphy',
'notebooks.type_lined_desc': 'Lined notebook for text',
'notebooks.type_grid_desc': 'Checkered notebook for math',
'notebooks.type_blank_desc': 'Blank notebook for free design',
'notebooks.type_dotted_desc': 'Dotted notebook for notes',
'notebooks.type_music_desc': 'Music notebook with staff lines',
'notebooks.type_calligraphy_desc': 'Calligraphy notebook for writing practice',
'notebooks.no_subject': 'No Subject',
'notebooks.no_class': 'No Class',
'notebooks.all_subjects': 'All Subjects',
'notebooks.page_count': '{count} pages',
'notebooks.page': 'Page',
'notebooks.pages': 'Pages',
'notebooks.new_page': 'New Page',
'notebooks.add_page': 'Add Page',
'notebooks.delete_page': 'Delete Page',
'notebooks.delete_page_confirm': 'Really delete this page?',
'notebooks.page_title': 'Page Title',
'notebooks.page_title_placeholder': 'Enter page name',
'notebooks.page_content': 'Page Content',
'notebooks.bookmark': 'Bookmark',
'notebooks.bookmark_add': 'Add Bookmark',
'notebooks.bookmark_remove': 'Remove Bookmark',
'notebooks.drawing_mode': 'Drawing Mode',
'notebooks.drawing_mode_toggle': 'Draw',
'notebooks.drawing_mode_exit': 'Back to Text',
'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component',
'notebooks.back_to_library': 'Back to Library',
'notebooks.open_notebook': 'Open Notebook',
'notebooks.created': 'Notebook created',
'notebooks.updated': 'Notebook updated',
'notebooks.archived_toast': 'Notebook archived',
'notebooks.unarchived_toast': 'Notebook restored',
'notebooks.deleted_toast': 'Notebook deleted',
'notebooks.page_saved': 'Page saved',
'notebooks.page_added': 'Page added',
'notebooks.page_deleted': 'Page deleted',
'notebooks.error_load': 'Error loading notebooks',
'notebooks.error_save': 'Error saving',
'notebooks.error_create': 'Error creating notebook',
'notebooks.loading': 'Loading notebooks…',
'notebooks.public': 'Shared',
'notebooks.private': 'Private',
'notebooks.filter_all': 'All',
'notebooks.filter_subject': 'Filter by subject',
'notebooks.eco_message': 'Save paper, protect the environment',
'notebooks.eco_tip': '1 notebook = ~50 pages of paper saved',
'notebooks.eco_total': '{count} digital notebooks = ~{pages} pages of paper saved',
'notebooks.search_placeholder': 'Search notebooks…',
'notebooks.detail_title': 'Notebook Details',
'notebooks.page_nav': 'Page Navigation',
'notebooks.content_area': 'Content Area',
'notebooks.save_page': 'Save Page',
'notebooks.color_emerald': 'Emerald',
'notebooks.color_blue': 'Blue',
'notebooks.color_red': 'Red',
'notebooks.color_yellow': 'Yellow',
'notebooks.color_purple': 'Purple',
'notebooks.color_orange': 'Orange',
'notebooks.color_teal': 'Teal',
'notebooks.color_pink': 'Pink',
'notebooks.title_required': 'Please enter a title',
'notebooks.total_notebooks': 'Total Notebooks',
'notebooks.total_pages': 'Total Pages',
'notebooks.paper_saved': 'Paper Saved',
'notebooks.pages_unit': 'Pages',
}; };
const translations: Record<string, TranslationDict> = { de, en }; const translations: Record<string, TranslationDict> = { de, en };
+3 -1
View File
@@ -22,7 +22,9 @@ export type ViewName =
| 'behavior' | 'behavior'
| 'coverage' | 'coverage'
| 'rubrics' | 'rubrics'
| 'comments'; | 'comments'
| 'notebooks'
| 'drawing';
export interface CurrentUser { export interface CurrentUser {
id: string; id: string;