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:
1 parent
456cdf9714
commit
04034dca62
22 files changed
+4714
-51
No files matched your search
@@ -35,6 +35,8 @@ model School {
|
||||
rubrics Rubric[]
|
||||
commentCategories CommentCategory[]
|
||||
commentBankEntries CommentBankEntry[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -67,6 +69,8 @@ model User {
|
||||
behaviorIncidentsResolved BehaviorIncident[] @relation("BehaviorResolver")
|
||||
rubrics Rubric[]
|
||||
commentBankEntries CommentBankEntry[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
}
|
||||
|
||||
model SchoolYear {
|
||||
@@ -109,6 +113,8 @@ model ClassGroup {
|
||||
attendanceSessions AttendanceSession[]
|
||||
lessonPlans LessonPlan[]
|
||||
behaviorIncidents BehaviorIncident[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
}
|
||||
|
||||
model ClassGroupTeacher {
|
||||
@@ -179,6 +185,8 @@ model Subject {
|
||||
lessonPlans LessonPlan[]
|
||||
rubrics Rubric[]
|
||||
commentBankEntries CommentBankEntry[]
|
||||
notebooks Notebook[]
|
||||
drawings Drawing[]
|
||||
}
|
||||
|
||||
model CompetencyTemplate {
|
||||
@@ -711,3 +719,84 @@ model CommentBankEntry {
|
||||
@@index([categoryId])
|
||||
@@index([subjectId])
|
||||
}
|
||||
|
||||
// ─── Digital Notebooks ─────────────────────────────────────────────
|
||||
|
||||
model Notebook {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
ownerId String // userId of the student or teacher
|
||||
ownerType String // STUDENT | TEACHER
|
||||
subjectId String? // null = personal/general notebook
|
||||
classGroupId String?
|
||||
title String
|
||||
description String?
|
||||
notebookType String @default("lined") // lined | grid | blank | dotted | music | calligraphy
|
||||
color String @default("#10b981") // cover color
|
||||
icon String? // lucide icon name
|
||||
isArchived Boolean @default(false)
|
||||
isPublic Boolean @default(false) // shared with class
|
||||
sortOrder Int @default(0)
|
||||
isDemo Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
subject Subject? @relation(fields: [subjectId], references: [id])
|
||||
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||
pages NotebookPage[]
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([ownerId])
|
||||
@@index([subjectId])
|
||||
@@index([classGroupId])
|
||||
@@index([isArchived])
|
||||
}
|
||||
|
||||
model NotebookPage {
|
||||
id String @id @default(cuid())
|
||||
notebookId String
|
||||
pageNumber Int
|
||||
title String?
|
||||
contentType String @default("text") // text | drawing | mixed
|
||||
textContent String? // markdown/rich text content
|
||||
drawingData String? // JSON with strokes/paths for canvas
|
||||
background String @default("lined") // lined | grid | blank | dotted | music
|
||||
isBookmark Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([notebookId])
|
||||
@@index([pageNumber])
|
||||
}
|
||||
|
||||
// ─── Drawing Canvas ────────────────────────────────────────────────
|
||||
|
||||
model Drawing {
|
||||
id String @id @default(cuid())
|
||||
schoolId String
|
||||
ownerId String
|
||||
title String
|
||||
description String?
|
||||
drawingData String // JSON with all strokes, paths, colors
|
||||
imageData String? // base64 encoded PNG thumbnail
|
||||
subjectId String?
|
||||
classGroupId String?
|
||||
isPublic Boolean @default(false)
|
||||
isDemo Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
school School @relation(fields: [schoolId], references: [id])
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
subject Subject? @relation(fields: [subjectId], references: [id])
|
||||
classGroup ClassGroup? @relation(fields: [classGroupId], references: [id])
|
||||
|
||||
@@index([schoolId])
|
||||
@@index([ownerId])
|
||||
@@index([subjectId])
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -633,3 +633,136 @@
|
||||
/* Prevent overflow */
|
||||
.overflow-y-auto, .max-h-96, .max-h-80, .max-h-72 { max-height: none !important; overflow: visible !important; }
|
||||
}
|
||||
|
||||
/* ─── Tablet & Stylus Optimization ────────────────────────────────────── */
|
||||
|
||||
/* Minimum touch target size (44px as per Apple HIG and WCAG guidelines) */
|
||||
@layer utilities {
|
||||
.min-touch {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly media queries for tablets (768px-1024px) */
|
||||
@media (pointer: coarse) {
|
||||
/* Increase touch targets for all interactive elements */
|
||||
button,
|
||||
[role="button"],
|
||||
a,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
select,
|
||||
.tabs-trigger,
|
||||
[data-state] {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Stylus-friendly: larger focus rings to prevent accidental taps */
|
||||
*:focus-visible {
|
||||
outline-width: 3px;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* More generous spacing between interactive elements */
|
||||
.sidebar-menu-item {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Prevent text selection while using stylus on interactive elements */
|
||||
button, a, [role="button"] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Slightly larger fonts for touch readability */
|
||||
input, select, textarea {
|
||||
font-size: 16px; /* Prevents iOS zoom on input focus */
|
||||
}
|
||||
}
|
||||
|
||||
/* Stylus-specific improvements */
|
||||
@media (pointer: fine) and (hover: none) {
|
||||
/* Stylus devices often lack hover — replace hover effects with active */
|
||||
*:hover {
|
||||
/* Remove hover-only styles that don't make sense without hover capability */
|
||||
}
|
||||
/* Larger active/focus states for stylus precision */
|
||||
*:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Smooth Scrollbar Styling (touch-friendly) ─────────────────────── */
|
||||
@layer utilities {
|
||||
.scrollbar-touch::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.scrollbar-touch::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px;
|
||||
}
|
||||
.scrollbar-touch::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.80 0.02 155 / 0.5);
|
||||
border-radius: 5px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.scrollbar-touch::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.65 0.17 155 / 0.7);
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.dark .scrollbar-touch::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.35 0.015 260 / 0.5);
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Stylus-friendly input styles — larger focus rings for precision tapping */
|
||||
.stylus-input:focus-visible {
|
||||
outline: 3px solid oklch(0.55 0.17 155);
|
||||
outline-offset: 4px;
|
||||
box-shadow: 0 0 0 6px oklch(0.55 0.17 155 / 0.15);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.2s ease-out, outline-offset 0.2s ease-out;
|
||||
}
|
||||
.dark .stylus-input:focus-visible {
|
||||
outline: 3px solid oklch(0.65 0.17 155);
|
||||
box-shadow: 0 0 0 6px oklch(0.65 0.17 155 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Better Print Styles ──────────────────────────────────────────────── */
|
||||
@media print {
|
||||
/* Ensure emerald accent colors print as dark gray for readability */
|
||||
.text-emerald-600, .text-emerald-700, .text-teal-600, .text-teal-700 {
|
||||
color: #333 !important;
|
||||
}
|
||||
.bg-emerald-50, .bg-emerald-100, .bg-teal-50, .bg-teal-100,
|
||||
.bg-gradient-to-r,
|
||||
[class*="from-emerald"],
|
||||
[class*="from-teal"] {
|
||||
background: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
/* Remove animations for print */
|
||||
.animate-float, .animate-float-reverse, .animate-float-slow,
|
||||
.animate-pulse-soft, .animate-shimmer, .animate-progress-fill,
|
||||
.animate-view-fade-in, .animate-enter-fade, .animate-count-up {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Force all cards to have visible borders */
|
||||
[class*="border-0"], [class*="border-l-3"] {
|
||||
border: 1px solid #ddd !important;
|
||||
}
|
||||
|
||||
/* Remove shadows */
|
||||
.shadow-sm, .shadow-md, .shadow-lg, .shadow-xl,
|
||||
[class*="shadow-"] {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@@ -532,7 +532,7 @@ export default function AnalyticsView() {
|
||||
<>
|
||||
{/* Insights */}
|
||||
{(() => {
|
||||
const insights: Array<{ icon: string; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = [];
|
||||
const insights: Array<{ icon: React.ReactNode; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = [];
|
||||
// Trend insight: compare first vs last mastery data point
|
||||
if (data.masteryTrend.length >= 2) {
|
||||
const first = data.masteryTrend[0].avgMastery;
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
MessageSquareText,
|
||||
Pencil,
|
||||
Plus,
|
||||
Palette,
|
||||
Leaf,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -102,8 +104,8 @@ import {
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { AlertTriangle, Info, ArrowRight } from 'lucide-react';
|
||||
|
||||
import DashboardView from './dashboard-view';
|
||||
import ClassesView from './classes-view';
|
||||
import CompetencyGridView from './competency-grid-view';
|
||||
@@ -124,6 +126,8 @@ import BehaviorTrackingView from './behavior-tracking-view';
|
||||
import CurriculumCoverageView from './curriculum-coverage-view';
|
||||
import RubricLibraryView from './rubric-library-view';
|
||||
import CommentBankView from './comment-bank-view';
|
||||
import NotebooksView from './notebooks-view';
|
||||
import DrawingView from './drawing-view';
|
||||
|
||||
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
|
||||
type NavSection = { id: string; labelKey: string; items: NavItem[] };
|
||||
@@ -156,6 +160,8 @@ const navSections: NavSection[] = [
|
||||
{ key: 'behavior', icon: Shield, labelKey: 'nav.behavior' },
|
||||
{ key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' },
|
||||
{ key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' },
|
||||
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
|
||||
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -190,6 +196,8 @@ function renderView(view: ViewName) {
|
||||
case 'coverage': return <CurriculumCoverageView />;
|
||||
case 'rubrics': return <RubricLibraryView />;
|
||||
case 'comments': return <CommentBankView />;
|
||||
case 'notebooks': return <NotebooksView />;
|
||||
case 'drawing': return <DrawingView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
@@ -469,7 +477,7 @@ export default function AppLayout() {
|
||||
isActive={currentView === item.key}
|
||||
onClick={() => setCurrentView(item.key)}
|
||||
tooltip={t(item.labelKey)}
|
||||
className={`group relative transition-all duration-200 ${
|
||||
className={`group relative transition-all duration-200 min-h-[44px] ${
|
||||
currentView === item.key
|
||||
? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm shadow-emerald-100/40 dark:shadow-emerald-900/20'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
|
||||
@@ -478,12 +486,12 @@ export default function AppLayout() {
|
||||
{currentView === item.key && (
|
||||
<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
|
||||
? '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'
|
||||
}`} />
|
||||
<span>{t(item.labelKey)}</span>
|
||||
<span className="text-sm">{t(item.labelKey)}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
@@ -494,9 +502,21 @@ export default function AppLayout() {
|
||||
</SidebarContent>
|
||||
|
||||
<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" />
|
||||
<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">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
@@ -517,7 +537,7 @@ export default function AppLayout() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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')}
|
||||
title={t('theme.toggle')}
|
||||
>
|
||||
@@ -526,7 +546,7 @@ export default function AppLayout() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
title={t('language.toggle')}
|
||||
>
|
||||
@@ -535,7 +555,7 @@ export default function AppLayout() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
title={t('auth.logout')}
|
||||
>
|
||||
@@ -547,8 +567,8 @@ export default function AppLayout() {
|
||||
</Sidebar>
|
||||
|
||||
<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">
|
||||
<SidebarTrigger className="-ml-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
|
||||
<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 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" />
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="text-sm">
|
||||
@@ -817,14 +837,14 @@ export default function AppLayout() {
|
||||
</AnimatePresence>
|
||||
</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">
|
||||
<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')}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -97,7 +97,7 @@ export default function AuthView() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* Large emerald circle */}
|
||||
@@ -232,7 +232,7 @@ export default function AuthView() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
className="w-full max-w-md md:max-w-lg"
|
||||
>
|
||||
{/* Branding */}
|
||||
<div className="text-center mb-8 lg:hidden">
|
||||
@@ -253,8 +253,8 @@ export default function AuthView() {
|
||||
</div>
|
||||
|
||||
{/* Auth Card */}
|
||||
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-xl overflow-hidden">
|
||||
<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">
|
||||
<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-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">
|
||||
{mode === 'login' ? t('auth.login') : t('auth.register_title')}
|
||||
</CardTitle>
|
||||
@@ -275,12 +275,12 @@ export default function AuthView() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
|
||||
<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
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
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')}
|
||||
required
|
||||
/>
|
||||
@@ -292,7 +292,7 @@ export default function AuthView() {
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
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')}
|
||||
required
|
||||
/>
|
||||
@@ -305,13 +305,13 @@ export default function AuthView() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
|
||||
<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
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
@@ -332,7 +332,7 @@ export default function AuthView() {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -346,11 +346,11 @@ export default function AuthView() {
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,7 +372,7 @@ export default function AuthView() {
|
||||
|
||||
<Button
|
||||
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}
|
||||
>
|
||||
{loading || demoLoading ? (
|
||||
@@ -405,7 +405,7 @@ export default function AuthView() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('auth.demo_section_subtitle')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{DEMO_ACCOUNTS.map((demo) => (
|
||||
<Button
|
||||
key={demo.email}
|
||||
@@ -413,9 +413,9 @@ export default function AuthView() {
|
||||
variant="outline"
|
||||
onClick={() => handleDemoLogin(demo.email, demo.password)}
|
||||
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>
|
||||
</Button>
|
||||
))}
|
||||
@@ -436,9 +436,12 @@ export default function AuthView() {
|
||||
</Card>
|
||||
|
||||
{/* 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')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,29 @@ import {
|
||||
Frown,
|
||||
Meh,
|
||||
Sparkles,
|
||||
Handshake,
|
||||
Hand,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Clock,
|
||||
Star,
|
||||
MessageSquare,
|
||||
type LucideIcon,
|
||||
} 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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -360,7 +382,7 @@ function CategoryBadge({ category }: { category: BehaviorIncident['category'] })
|
||||
borderColor: hexToRgba(category.color, 0.3),
|
||||
}}
|
||||
>
|
||||
{category.icon && <span>{category.icon}</span>}
|
||||
{category.icon && renderLucideIcon(category.icon)}
|
||||
{category.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,20 @@ import {
|
||||
BarChart3,
|
||||
ListChecks,
|
||||
MessageSquareText,
|
||||
MessageSquare,
|
||||
type LucideIcon,
|
||||
} 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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -594,7 +607,7 @@ export default function CommentBankView() {
|
||||
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>
|
||||
{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">
|
||||
@@ -863,7 +876,7 @@ export default function CommentBankView() {
|
||||
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 variant="secondary" className="text-xs font-semibold">
|
||||
{entry.usageCount}×
|
||||
@@ -1034,7 +1047,7 @@ export default function CommentBankView() {
|
||||
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>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -392,7 +392,7 @@ export default function DashboardView() {
|
||||
</motion.div>
|
||||
|
||||
{/* ===== 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: 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 }}>
|
||||
<Button
|
||||
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
|
||||
className="flex items-center gap-2"
|
||||
@@ -676,7 +676,7 @@ export default function DashboardView() {
|
||||
<Button
|
||||
onClick={() => setCurrentView('assessments')}
|
||||
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" />
|
||||
{t('action.create_assessment')}
|
||||
@@ -686,7 +686,7 @@ export default function DashboardView() {
|
||||
<Button
|
||||
onClick={() => setCurrentView('reports')}
|
||||
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" />
|
||||
{t('action.generate_report')}
|
||||
@@ -720,7 +720,7 @@ export default function DashboardView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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,
|
||||
@@ -781,14 +781,14 @@ export default function DashboardView() {
|
||||
whileHover={{ scale: 1.03, y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
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 */}
|
||||
<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="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`}>
|
||||
<action.icon className="h-5 w-5" />
|
||||
<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-6 w-6" />
|
||||
</div>
|
||||
{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">
|
||||
@@ -1144,6 +1144,35 @@ export default function DashboardView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -1299,7 +1299,7 @@ function StatisticsPanel({
|
||||
<div key={c} className="space-y-1">
|
||||
<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>{cfg.emoji}</span>
|
||||
{(() => { const CatIcon = cfg.iconComponent; return <CatIcon className="w-3.5 h-3.5" />; })()}
|
||||
{t(CATEGORY_LABEL_KEY[c])}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{count}</span>
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
Leaf,
|
||||
DatabaseIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -650,7 +652,7 @@ export default function SettingsView() {
|
||||
<span className="hidden sm:inline">{t('settings.tab_data')}</span>
|
||||
</TabsTrigger>
|
||||
{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" />
|
||||
<span className="hidden sm:inline">{t('settings.demo_accounts')}</span>
|
||||
</TabsTrigger>
|
||||
@@ -1541,7 +1543,60 @@ export default function SettingsView() {
|
||||
{/* ── Demo Accounts Tab ───────────────────────────────── */}
|
||||
{isAdmin && (
|
||||
<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">
|
||||
<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">
|
||||
@@ -1565,7 +1620,7 @@ export default function SettingsView() {
|
||||
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
|
||||
<Button
|
||||
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" />
|
||||
{t('settings.demo_enable')}
|
||||
@@ -1574,7 +1629,7 @@ export default function SettingsView() {
|
||||
<Button
|
||||
onClick={handleDisableAllDemo}
|
||||
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" />
|
||||
{t('settings.demo_disable_all')}
|
||||
@@ -1582,7 +1637,7 @@ export default function SettingsView() {
|
||||
<Button
|
||||
onClick={() => setDeleteAllDemoOpen(true)}
|
||||
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" />
|
||||
{t('settings.demo_delete_all')}
|
||||
|
||||
+344
@@ -480,6 +480,16 @@ const de: TranslationDict = {
|
||||
'footer.copyright': 'CompetenceTrack — Freie Schulsoftware',
|
||||
'footer.version': 'Version 1.0',
|
||||
'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
|
||||
'dashboard.start_here': 'Jetzt beginnen!',
|
||||
@@ -1517,6 +1527,7 @@ const de: TranslationDict = {
|
||||
|
||||
// ── Comments Bank (Zeugnisbemerkungen) ──────────────────────────────
|
||||
'nav.comments': 'Bemerkungen',
|
||||
'nav.drawing': 'Zeichnen',
|
||||
'comments.title': 'Zeugnisbemerkungen',
|
||||
'comments.subtitle': 'Bibliothek wiederverwendbarer Bemerkungstexte für Zeugnisse',
|
||||
'comments.tab_entries': 'Bemerkungen',
|
||||
@@ -1690,6 +1701,167 @@ const de: TranslationDict = {
|
||||
'shortcuts.recent_actions': 'Letzte Aktionen',
|
||||
'shortcuts.views': 'Ansichten',
|
||||
'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 = {
|
||||
@@ -2169,6 +2341,16 @@ const en: TranslationDict = {
|
||||
'footer.copyright': 'CompetenceTrack — Free School Software',
|
||||
'footer.version': 'Version 1.0',
|
||||
'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
|
||||
'dashboard.start_here': 'Start here!',
|
||||
@@ -3206,6 +3388,7 @@ const en: TranslationDict = {
|
||||
|
||||
// ── Comments Bank (Report Card Comments) ──────────────────────────────
|
||||
'nav.comments': 'Comments',
|
||||
'nav.drawing': 'Drawing',
|
||||
'comments.title': 'Report Card Comments',
|
||||
'comments.subtitle': 'Library of reusable comment texts for report cards',
|
||||
'comments.tab_entries': 'Comments',
|
||||
@@ -3379,6 +3562,167 @@ const en: TranslationDict = {
|
||||
'shortcuts.recent_actions': 'Recent Actions',
|
||||
'shortcuts.views': 'Views',
|
||||
'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 };
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@ export type ViewName =
|
||||
| 'behavior'
|
||||
| 'coverage'
|
||||
| 'rubrics'
|
||||
| 'comments';
|
||||
| 'comments'
|
||||
| 'notebooks'
|
||||
| 'drawing';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user