Round 19: Professional Notebooks, Digital Rewards, GDPR, Role-Based Dashboard, Deep Styling Polish

- Fixed attendance API 403: added TEACHER school membership check
- Fixed competitions API 403: cross-school access, null schoolId, public fallback
- Fixed GDPR export API 500: 16 Prisma field name mismatches
- Enhanced notebooks: page templates (Cornell, Mind Map, Venn, T-Chart, Weekly Planner),
  sticky notes, search, page duplication, sections/chapters, highlighter tool
- Added scratch pad with DrawingCanvas in assessments for hand-written solutions
- Role-based dashboard: TEACHER (competence radar, upcoming lessons),
  STUDENT (grades trend, competitions), PARENT (children grades, calendar),
  SCHOOL_ADMIN (system health, audit log, user management)
- Digital reward system: 16 demo rewards, points earning/redemption,
  categories (streaming, shopping, experiences, merchandise, privileges)
- GDPR compliance: consent management, data retention, cookie consent
- Deep styling polish: glassmorphism, gradient headers, animated sidebar,
  card hover effects, progress bar animations, timeline dots, FAB button
- 160+ new i18n keys (DE/EN)
This commit is contained in:
Mustafa Cetin committed 2026-07-29 16:23:18 +00:00
1 parent 86e4b504e4
commit 6d9b42ac37
43 files changed
+4569 -140

No files matched your search

+2 -2
View File
@@ -54,7 +54,7 @@ export async function GET(request: Request) {
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
(session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'TEACHER') &&
session.user.schoolId &&
classGroup.schoolId !== session.user.schoolId
) {
@@ -200,7 +200,7 @@ export async function POST(request: Request) {
}
if (
session.user?.role === 'SCHOOL_ADMIN' &&
(session.user?.role === 'SCHOOL_ADMIN' || session.user?.role === 'TEACHER') &&
session.user.schoolId &&
classGroup.schoolId !== session.user.schoolId
) {
@@ -24,13 +24,25 @@ async function getLeaderboard(
return NextResponse.json({ error: 'Competition not found' }, { status: 404 });
}
// Access control
// Access control: authenticated users can see their own school's competitions or public ones
// Also allow participants (e.g. inter-school competitions) and users without a schoolId viewing public ones
const userSchoolId = session.user?.schoolId;
const isSameSchool = userSchoolId === competition.schoolId;
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.schoolId !== competition.schoolId &&
!isSameSchool &&
!competition.isPublic
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Check if the user is a participant in this competition
const participant = await db.competitionParticipant.findFirst({
where: {
competitionId: id,
userId: session.user!.id,
},
});
if (!participant) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const leaderboard = await db.competitionLeaderboard.findMany({
+15 -3
View File
@@ -49,13 +49,25 @@ async function getRewards(
return NextResponse.json({ error: 'Competition not found' }, { status: 404 });
}
// Access control
// Access control: authenticated users can see their own school's competitions or public ones
// Also allow participants (e.g. inter-school competitions) and users without a schoolId viewing public ones
const userSchoolId = session.user?.schoolId;
const isSameSchool = userSchoolId === competition.schoolId;
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.schoolId !== competition.schoolId &&
!isSameSchool &&
!competition.isPublic
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Check if the user is a participant in this competition
const participant = await db.competitionParticipant.findFirst({
where: {
competitionId: id,
userId: session.user!.id,
},
});
if (!participant) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const rewards = await db.competitionReward.findMany({
+11 -3
View File
@@ -88,11 +88,19 @@ async function getCompetition(
return NextResponse.json({ error: 'Competition not found' }, { status: 404 });
}
// Access control: non-admins can only see their own school's competitions or public ones
// Access control: authenticated users can see their own school's competitions or public ones
// Users without a schoolId (e.g. some PARENTs) can still see public competitions
// Participants can always view the competition they are in
const userSchoolId = session.user?.schoolId;
const isSameSchool = userSchoolId === competition.schoolId;
const isParticipant = competition.participants?.some(
(p) => p.userId === session.user!.id || p.participantId === session.user!.id
);
if (
session.user?.role !== 'SUPER_ADMIN' &&
session.user?.schoolId !== competition.schoolId &&
!competition.isPublic
!isSameSchool &&
!competition.isPublic &&
!isParticipant
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
+62 -3
View File
@@ -53,13 +53,72 @@ async function getCompetitions(request: Request): Promise<NextResponse> {
const offset = parseInt(searchParams.get('offset') || '0');
let schoolId: string | undefined;
if (session.user?.role === 'SCHOOL_ADMIN') {
if (session.user?.role === 'SUPER_ADMIN') {
// Super admins can query any school or see all
schoolId = schoolIdParam ?? undefined;
} else if (session.user?.role === 'SCHOOL_ADMIN') {
schoolId = session.user.schoolId ?? undefined;
} else {
schoolId = schoolIdParam ?? session.user?.schoolId ?? undefined;
// TEACHER, STUDENT, PARENT — always use the session schoolId to prevent
// cross-school access which would lead to 403 on sub-resources
schoolId = session.user?.schoolId ?? undefined;
}
if (!schoolId) return NextResponse.json({ competitions: [], total: 0 });
if (!schoolId) {
// SUPER_ADMIN without a schoolId filter, or users without a schoolId
if (session.user?.role === 'SUPER_ADMIN') {
// Super admins can see all competitions
const where: Record<string, unknown> = { deletedAt: null };
if (statusParam) where.status = statusParam;
if (competitionTypeParam) where.competitionType = competitionTypeParam;
if (categoryParam) where.category = categoryParam;
const isPublicParam = searchParams.get('isPublic');
if (isPublicParam === 'true') where.isPublic = true;
const [competitions, total] = await Promise.all([
db.competition.findMany({
where,
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
skip: offset,
take: limit,
include: {
school: { select: { id: true, name: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
_count: { select: { participants: true, rewards: true } },
},
}),
db.competition.count({ where }),
]);
return NextResponse.json({ competitions, total });
}
// Other users without a schoolId can still see public competitions
const where: Record<string, unknown> = { isPublic: true, deletedAt: null };
if (statusParam) where.status = statusParam;
if (competitionTypeParam) where.competitionType = competitionTypeParam;
if (categoryParam) where.category = categoryParam;
const [competitions, total] = await Promise.all([
db.competition.findMany({
where,
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
skip: offset,
take: limit,
include: {
school: { select: { id: true, name: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
subject: { select: { id: true, name: true } },
_count: { select: { participants: true, rewards: true } },
},
}),
db.competition.count({ where }),
]);
return NextResponse.json({ competitions, total });
}
const where: Record<string, unknown> = { schoolId, deletedAt: null };
if (statusParam) where.status = statusParam;
+22 -19
View File
@@ -55,8 +55,9 @@ export async function GET() {
select: {
id: true,
competencyId: true,
level: true,
comment: true,
masteryLevelValue: true,
note: true,
date: true,
createdAt: true,
updatedAt: true,
},
@@ -83,12 +84,12 @@ export async function GET() {
where: { generatedByUserId: userId },
select: {
id: true,
type: true,
period: true,
status: true,
createdAt: true,
generatedAt: true,
},
take: 200,
orderBy: { createdAt: 'desc' },
orderBy: { generatedAt: 'desc' },
});
// Fetch audit log entries
@@ -97,12 +98,12 @@ export async function GET() {
select: {
id: true,
action: true,
entity: true,
entityType: true,
entityId: true,
createdAt: true,
timestamp: true,
},
take: 200,
orderBy: { createdAt: 'desc' },
orderBy: { timestamp: 'desc' },
});
// Fetch data export requests
@@ -121,7 +122,7 @@ export async function GET() {
// Fetch teacher notes
const teacherNotes = await db.teacherNote.findMany({
where: { userId },
where: { teacherId: userId },
select: {
id: true,
content: true,
@@ -154,7 +155,7 @@ export async function GET() {
type: true,
title: true,
message: true,
read: true,
isRead: true,
createdAt: true,
},
take: 200,
@@ -176,7 +177,7 @@ export async function GET() {
// Fetch homework
const homeworks = await db.homework.findMany({
where: { createdById: userId },
where: { teacherId: userId },
select: {
id: true,
title: true,
@@ -191,13 +192,17 @@ export async function GET() {
let studentData = null;
if (user.role === 'STUDENT') {
const student = await db.student.findFirst({
where: { email: user.email, deletedAt: null },
where: {
firstName: user.firstName,
lastName: user.lastName,
schoolId: user.schoolId ?? undefined,
deletedAt: null,
},
select: {
id: true,
firstName: true,
lastName: true,
dateOfBirth: true,
gender: true,
enrollments: {
select: {
id: true,
@@ -209,8 +214,8 @@ export async function GET() {
learningProgressEntries: {
select: {
id: true,
level: true,
comment: true,
masteryLevelValue: true,
note: true,
createdAt: true,
},
take: 200,
@@ -220,11 +225,9 @@ export async function GET() {
select: {
id: true,
score: true,
comment: true,
createdAt: true,
note: true,
},
take: 200,
orderBy: { createdAt: 'desc' },
},
},
});
@@ -235,7 +238,7 @@ export async function GET() {
let parentLinks = null;
if (user.role === 'PARENT') {
parentLinks = await db.parentStudentLink.findMany({
where: { userId },
where: { parentId: userId },
select: {
id: true,
relationship: true,
+126
View File
@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET /api/reward-points — Get points balance and history
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userId = session.userId;
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school associated' }, { status: 400 });
}
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '50', 10);
// Fetch points entries
const pointsEntries = await db.rewardPoints.findMany({
where: { userId, schoolId },
orderBy: { createdAt: 'desc' },
take: limit,
});
// Fetch redemptions (spent points)
const redemptions = await db.rewardRedemption.findMany({
where: { userId, status: { in: ['pending', 'approved', 'fulfilled'] } },
include: { reward: { select: { title: true, category: true } } },
orderBy: { createdAt: 'desc' },
take: limit,
});
// Calculate balance
const totalEarned = pointsEntries.reduce((sum, e) => sum + e.points, 0);
const totalSpent = redemptions.reduce((sum, r) => sum + r.pointsSpent, 0);
const balance = totalEarned - totalSpent;
return NextResponse.json({
balance,
totalEarned,
totalSpent,
pointsHistory: pointsEntries,
redemptions,
});
} catch (error) {
console.error('Error fetching reward points:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// POST /api/reward-points — Award points to a user
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user?.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school associated' }, { status: 400 });
}
const body = await request.json();
const { userId, points, source, sourceId, description } = body;
if (!userId || !points || !source) {
return NextResponse.json({ error: 'userId, points, and source are required' }, { status: 400 });
}
const validSources = ['competition', 'grade', 'attendance', 'homework', 'bonus'];
if (!validSources.includes(source)) {
return NextResponse.json({ error: 'Invalid source' }, { status: 400 });
}
// Verify the target user belongs to the same school
const targetUser = await db.user.findFirst({
where: { id: userId, schoolId, deletedAt: null },
});
if (!targetUser) {
return NextResponse.json({ error: 'Target user not found in your school' }, { status: 404 });
}
const pointsEntry = await db.rewardPoints.create({
data: {
userId,
schoolId,
points: parseInt(String(points), 10),
source,
sourceId: sourceId || null,
description: description || null,
},
});
// Create audit log
await db.auditLog.create({
data: {
userId: session.userId,
schoolId,
action: 'POINTS_AWARDED',
entityType: 'RewardPoints',
entityId: pointsEntry.id,
changes: JSON.stringify({
targetUserId: userId,
points: pointsEntry.points,
source,
description: description || null,
}),
},
});
return NextResponse.json(pointsEntry, { status: 201 });
} catch (error) {
console.error('Error awarding points:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET /api/rewards/[id] — Get a single reward
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 reward = await db.reward.findFirst({
where: { id, deletedAt: null },
include: {
_count: {
select: { redemptions: true },
},
},
});
if (!reward) {
return NextResponse.json({ error: 'Reward not found' }, { status: 404 });
}
return NextResponse.json(reward);
} catch (error) {
console.error('Error fetching reward:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// PUT /api/rewards/[id] — Update a reward
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 role = session.user?.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { id } = await params;
const existing = await db.reward.findFirst({ where: { id, deletedAt: null } });
if (!existing) {
return NextResponse.json({ error: 'Reward not found' }, { status: 404 });
}
const body = await request.json();
const { title, description, category, pointsCost, image, stock, isActive } = body;
const reward = await db.reward.update({
where: { id },
data: {
...(title !== undefined && { title }),
...(description !== undefined && { description: description || null }),
...(category !== undefined && { category }),
...(pointsCost !== undefined && { pointsCost: parseInt(String(pointsCost), 10) }),
...(image !== undefined && { image: image || null }),
...(stock !== undefined && { stock: stock != null ? parseInt(String(stock), 10) : null }),
...(isActive !== undefined && { isActive }),
},
});
return NextResponse.json(reward);
} catch (error) {
console.error('Error updating reward:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// DELETE /api/rewards/[id] — Soft delete a reward
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 role = session.user?.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { id } = await params;
const existing = await db.reward.findFirst({ where: { id, deletedAt: null } });
if (!existing) {
return NextResponse.json({ error: 'Reward not found' }, { status: 404 });
}
await db.reward.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting reward:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// POST /api/rewards/redeem — Redeem a reward
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const userId = session.userId;
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school associated' }, { status: 400 });
}
const body = await request.json();
const { rewardId } = body;
if (!rewardId) {
return NextResponse.json({ error: 'rewardId is required' }, { status: 400 });
}
// Fetch the reward
const reward = await db.reward.findFirst({
where: { id: rewardId, deletedAt: null, isActive: true },
});
if (!reward) {
return NextResponse.json({ error: 'Reward not found or inactive' }, { status: 404 });
}
// Check stock
if (reward.stock !== null && reward.stock <= 0) {
return NextResponse.json({ error: 'Out of stock' }, { status: 400 });
}
// Calculate user's current points balance
const pointsEntries = await db.rewardPoints.findMany({
where: { userId, schoolId },
select: { points: true },
});
const totalEarned = pointsEntries.reduce((sum, e) => sum + e.points, 0);
const redemptions = await db.rewardRedemption.findMany({
where: { userId, status: { in: ['pending', 'approved', 'fulfilled'] } },
select: { pointsSpent: true },
});
const totalSpent = redemptions.reduce((sum, r) => sum + r.pointsSpent, 0);
const currentBalance = totalEarned - totalSpent;
// Check if user has enough points
if (currentBalance < reward.pointsCost) {
return NextResponse.json(
{ error: 'Insufficient points', currentBalance, pointsCost: reward.pointsCost },
{ status: 400 }
);
}
// Create redemption
const redemption = await db.rewardRedemption.create({
data: {
rewardId,
userId,
pointsSpent: reward.pointsCost,
status: 'pending',
},
});
// Decrement stock if applicable
if (reward.stock !== null) {
await db.reward.update({
where: { id: rewardId },
data: { stock: reward.stock - 1 },
});
}
// Create audit log
await db.auditLog.create({
data: {
userId,
schoolId,
action: 'REWARD_REDEEMED',
entityType: 'Reward',
entityId: rewardId,
changes: JSON.stringify({
rewardTitle: reward.title,
pointsSpent: reward.pointsCost,
redemptionId: redemption.id,
}),
},
});
return NextResponse.json({
...redemption,
newBalance: currentBalance - reward.pointsCost,
}, { status: 201 });
} catch (error) {
console.error('Error redeeming reward:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
// GET /api/rewards — List rewards for the user's school
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 category = searchParams.get('category');
const includeInactive = searchParams.get('includeInactive') === 'true';
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school associated' }, { status: 400 });
}
const where: Record<string, unknown> = {
schoolId,
deletedAt: null,
};
if (!includeInactive) {
where.isActive = true;
}
if (category && category !== 'all') {
where.category = category;
}
const rewards = await db.reward.findMany({
where,
include: {
_count: {
select: { redemptions: true },
},
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(rewards);
} catch (error) {
console.error('Error fetching rewards:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// POST /api/rewards — Create a new reward
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const role = session.user?.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const schoolId = session.user?.schoolId;
if (!schoolId) {
return NextResponse.json({ error: 'No school associated' }, { status: 400 });
}
const body = await request.json();
const { title, description, category, pointsCost, image, stock, isActive, isDemo } = body;
if (!title || !category || !pointsCost) {
return NextResponse.json({ error: 'Title, category, and pointsCost are required' }, { status: 400 });
}
const validCategories = ['streaming', 'shopping', 'experience', 'merchandise', 'privilege'];
if (!validCategories.includes(category)) {
return NextResponse.json({ error: 'Invalid category' }, { status: 400 });
}
const reward = await db.reward.create({
data: {
schoolId,
title,
description: description || null,
category,
pointsCost: parseInt(String(pointsCost), 10),
image: image || null,
stock: stock != null ? parseInt(String(stock), 10) : null,
isActive: isActive !== false,
isDemo: isDemo === true,
},
});
return NextResponse.json(reward, { status: 201 });
} catch (error) {
console.error('Error creating reward:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+426
View File
@@ -2786,4 +2786,430 @@
background: linear-gradient(90deg, #10b981, #14b8a6);
border-radius: 0.75rem 0.75rem 0 0;
}
/* ─── Task 9: Deep Styling Polish ──────────────────────────────────── */
/* Activity Timeline with animated dots */
.activity-timeline {
position: relative;
padding-left: 1.75rem;
}
.activity-timeline::before {
content: '';
position: absolute;
left: 0.55rem;
top: 0.25rem;
bottom: 0.25rem;
width: 2px;
background: linear-gradient(to bottom, oklch(0.55 0.17 155 / 0.3), oklch(0.55 0.17 155 / 0.08));
border-radius: 1px;
}
.activity-timeline-item {
position: relative;
padding-bottom: 1rem;
}
.activity-timeline-item::before {
content: '';
position: absolute;
left: -1.35rem;
top: 0.4rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: oklch(0.55 0.17 155);
box-shadow: 0 0 0 3px oklch(0.55 0.17 155 / 0.15);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.activity-timeline-item:hover::before {
transform: scale(1.4);
box-shadow: 0 0 0 5px oklch(0.55 0.17 155 / 0.2);
}
.activity-timeline-item:last-child {
padding-bottom: 0;
}
.dark .activity-timeline::before {
background: linear-gradient(to bottom, oklch(0.65 0.17 155 / 0.3), oklch(0.65 0.17 155 / 0.08));
}
.dark .activity-timeline-item::before {
background: oklch(0.65 0.17 155);
box-shadow: 0 0 0 3px oklch(0.65 0.17 155 / 0.2);
}
/* Gradient Card Header */
.card-header-gradient {
position: relative;
overflow: hidden;
border-radius: 0.75rem 0.75rem 0 0;
}
.card-header-gradient::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.08) 0%, oklch(0.55 0.14 175 / 0.05) 100%);
pointer-events: none;
}
.dark .card-header-gradient::before {
background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.1) 0%, oklch(0.65 0.14 175 / 0.06) 100%);
}
/* Progress Ring (SVG-based) */
.progress-ring-circle {
transition: stroke-dashoffset 0.6s ease-out;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
/* Glassmorphism Toolbar (for notebooks, editors) */
.glass-toolbar {
background: oklch(1 0 0 / 0.75);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid oklch(1 0 0 / 0.35);
box-shadow:
0 4px 16px -4px oklch(0 0 0 / 0.1),
inset 0 0 0 1px oklch(1 0 0 / 0.2);
border-radius: 0.75rem;
}
.dark .glass-toolbar {
background: oklch(0.18 0.015 260 / 0.7);
border-color: oklch(1 0 0 / 0.08);
box-shadow:
0 4px 16px -4px oklch(0 0 0 / 0.4),
inset 0 0 0 1px oklch(1 0 0 / 0.05);
}
/* Floating Action Button */
.fab {
position: fixed;
bottom: 5rem;
right: 1.5rem;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 9999px;
background: linear-gradient(135deg, #10b981, #14b8a6);
color: white;
box-shadow: 0 6px 20px -4px oklch(0.55 0.17 155 / 0.45), 0 0 0 1px oklch(0.55 0.17 155 / 0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
}
.fab:hover {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 10px 30px -6px oklch(0.55 0.17 155 / 0.5), 0 0 0 1px oklch(0.55 0.17 155 / 0.3);
}
.fab:active {
transform: scale(0.95);
}
.dark .fab {
box-shadow: 0 6px 20px -4px oklch(0 0 0 / 0.5), 0 0 0 1px oklch(0.65 0.17 155 / 0.3);
background: linear-gradient(135deg, #10b981, #14b8a6);
}
.dark .fab:hover {
box-shadow: 0 10px 30px -6px oklch(0 0 0 / 0.6), 0 0 0 1px oklch(0.65 0.17 155 / 0.4);
}
/* Notebook Cover Gradient Effects */
.notebook-cover {
position: relative;
overflow: hidden;
border-radius: 0.75rem;
}
.notebook-cover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(180deg, oklch(0.55 0.17 155 / 0.12) 0%, transparent 100%);
pointer-events: none;
transition: height 0.3s ease;
}
.notebook-cover:hover::before {
height: 55%;
}
.dark .notebook-cover::before {
background: linear-gradient(180deg, oklch(0.65 0.17 155 / 0.15) 0%, transparent 100%);
}
/* Grade Badge Color Coding */
.grade-badge-excellent {
background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.15), oklch(0.55 0.14 175 / 0.1));
color: oklch(0.45 0.17 155);
border: 1px solid oklch(0.55 0.17 155 / 0.25);
}
.dark .grade-badge-excellent {
background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.2), oklch(0.65 0.14 175 / 0.15));
color: oklch(0.75 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.3);
}
.grade-badge-good {
background: linear-gradient(135deg, oklch(0.55 0.14 175 / 0.15), oklch(0.60 0.16 195 / 0.1));
color: oklch(0.45 0.14 175);
border: 1px solid oklch(0.55 0.14 175 / 0.25);
}
.dark .grade-badge-good {
background: linear-gradient(135deg, oklch(0.65 0.14 175 / 0.2), oklch(0.70 0.16 195 / 0.15));
color: oklch(0.75 0.14 175);
border-color: oklch(0.65 0.14 175 / 0.3);
}
.grade-badge-average {
background: linear-gradient(135deg, oklch(0.72 0.19 85 / 0.15), oklch(0.72 0.19 85 / 0.1));
color: oklch(0.55 0.19 85);
border: 1px solid oklch(0.72 0.19 85 / 0.25);
}
.dark .grade-badge-average {
background: linear-gradient(135deg, oklch(0.72 0.19 85 / 0.2), oklch(0.72 0.19 85 / 0.15));
color: oklch(0.82 0.19 85);
border-color: oklch(0.72 0.19 85 / 0.3);
}
.grade-badge-below {
background: linear-gradient(135deg, oklch(0.65 0.22 15 / 0.15), oklch(0.65 0.22 15 / 0.1));
color: oklch(0.55 0.22 15);
border: 1px solid oklch(0.65 0.22 15 / 0.25);
}
.dark .grade-badge-below {
background: linear-gradient(135deg, oklch(0.65 0.22 15 / 0.2), oklch(0.65 0.22 15 / 0.15));
color: oklch(0.75 0.22 15);
border-color: oklch(0.65 0.22 15 / 0.3);
}
/* Competence Flower Petal Animation */
@keyframes petal-bloom {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.05); opacity: 0.9; }
100% { transform: scale(1); opacity: 1; }
}
.animate-petal-bloom {
animation: petal-bloom 0.6s ease-out backwards;
}
/* Radar Chart Gradient Fill */
.radar-gradient-fill {
fill: oklch(0.55 0.17 155 / 0.2);
stroke: oklch(0.55 0.17 155);
stroke-width: 2;
}
.dark .radar-gradient-fill {
fill: oklch(0.65 0.17 155 / 0.25);
stroke: oklch(0.65 0.17 155);
}
/* Mastery Level Indicator */
.mastery-indicator {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.mastery-indicator:hover {
transform: scale(1.05);
}
.mastery-indicator-1 {
background: oklch(0.55 0.22 25 / 0.1);
color: oklch(0.55 0.22 25);
border: 1px solid oklch(0.55 0.22 25 / 0.2);
}
.mastery-indicator-2 {
background: oklch(0.72 0.19 85 / 0.1);
color: oklch(0.60 0.18 50);
border: 1px solid oklch(0.72 0.19 85 / 0.2);
}
.mastery-indicator-3 {
background: oklch(0.55 0.17 155 / 0.1);
color: oklch(0.45 0.17 155);
border: 1px solid oklch(0.55 0.17 155 / 0.2);
}
.mastery-indicator-4 {
background: oklch(0.55 0.14 175 / 0.1);
color: oklch(0.45 0.14 175);
border: 1px solid oklch(0.55 0.14 175 / 0.2);
}
.dark .mastery-indicator-1 {
background: oklch(0.55 0.22 25 / 0.15);
color: oklch(0.75 0.19 22);
border-color: oklch(0.55 0.22 25 / 0.3);
}
.dark .mastery-indicator-2 {
background: oklch(0.72 0.19 85 / 0.15);
color: oklch(0.82 0.19 85);
border-color: oklch(0.72 0.19 85 / 0.3);
}
.dark .mastery-indicator-3 {
background: oklch(0.65 0.17 155 / 0.15);
color: oklch(0.75 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.3);
}
.dark .mastery-indicator-4 {
background: oklch(0.65 0.14 175 / 0.15);
color: oklch(0.75 0.14 175);
border-color: oklch(0.65 0.14 175 / 0.3);
}
/* Animated Progress Bar with Gradient */
.progress-bar-animated {
position: relative;
height: 0.5rem;
border-radius: 9999px;
overflow: hidden;
background: oklch(0.93 0.005 155);
}
.dark .progress-bar-animated {
background: oklch(0.25 0.02 155);
}
.progress-bar-animated-fill {
height: 100%;
border-radius: 9999px;
background: linear-gradient(90deg, #10b981, #14b8a6);
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.progress-bar-animated-fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.3), transparent);
animation: shimmer-progress 2s ease-in-out infinite;
}
/* Gradient Header Bar */
.header-gradient {
background: linear-gradient(135deg, oklch(0.99 0.002 155) 0%, oklch(0.97 0.01 155) 50%, oklch(0.99 0.002 175) 100%);
border-bottom: 1px solid oklch(0.93 0.005 155);
}
.dark .header-gradient {
background: linear-gradient(135deg, oklch(0.15 0.01 260) 0%, oklch(0.13 0.01 260) 50%, oklch(0.15 0.015 155) 100%);
border-bottom-color: oklch(1 0 0 / 8%);
}
/* Sidebar Active Indicator with Animated Underline */
.sidebar-active-indicator {
position: relative;
}
.sidebar-active-indicator::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 2px;
background: linear-gradient(90deg, #10b981, #14b8a6);
border-radius: 1px;
transition: width 0.3s ease;
}
.sidebar-active-indicator.active::after {
width: 60%;
}
/* Notification Bell Pulse */
@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-12deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(4deg); }
60% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
.animate-bell-ring {
animation: bell-ring 0.8s ease-in-out;
}
/* Notification Badge Pulse */
@keyframes badge-pulse {
0%, 100% { box-shadow: 0 0 0 0 oklch(0.55 0.17 155 / 0.4); }
50% { box-shadow: 0 0 0 6px oklch(0.55 0.17 155 / 0); }
}
.animate-badge-pulse {
animation: badge-pulse 2s ease-in-out infinite;
}
/* Card Shadow Transition on Hover */
.card-shadow-transition {
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out, border-color 0.2s ease-out;
}
.card-shadow-transition:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px -6px oklch(0.55 0.17 155 / 0.12), 0 0 0 1px oklch(0.55 0.17 155 / 0.06);
border-color: oklch(0.55 0.17 155 / 0.2);
}
.dark .card-shadow-transition:hover {
box-shadow: 0 8px 24px -6px oklch(0 0 0 / 0.4), 0 0 0 1px oklch(0.65 0.17 155 / 0.12);
border-color: oklch(0.65 0.17 155 / 0.2);
}
/* Chart Gradient Fill */
.chart-gradient-fill {
fill: url(#emeraldGradient);
}
/* Breadcrumb Current View Indicator */
.breadcrumb-active-view {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, oklch(0.55 0.17 155 / 0.08), oklch(0.55 0.14 175 / 0.05));
color: oklch(0.45 0.17 155);
font-weight: 600;
font-size: 0.875rem;
border: 1px solid oklch(0.55 0.17 155 / 0.12);
}
.dark .breadcrumb-active-view {
background: linear-gradient(135deg, oklch(0.65 0.17 155 / 0.12), oklch(0.65 0.14 175 / 0.08));
color: oklch(0.75 0.17 155);
border-color: oklch(0.65 0.17 155 / 0.2);
}
/* Student Grid Animation */
@keyframes student-appear {
from { opacity: 0; transform: scale(0.9) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.animate-student-appear {
animation: student-appear 0.3s ease-out backwards;
}
/* Enhanced Tooltip/Popover Styling */
.popover-enhanced {
box-shadow:
0 8px 30px -6px oklch(0 0 0 / 0.12),
0 0 0 1px oklch(0.55 0.17 155 / 0.08);
border-radius: 0.75rem;
}
.dark .popover-enhanced {
box-shadow:
0 8px 30px -6px oklch(0 0 0 / 0.5),
0 0 0 1px oklch(0.65 0.17 155 / 0.12);
}
/* Gradient border for active class cards */
.class-card-active {
position: relative;
border: 2px solid transparent;
background-clip: padding-box;
}
.class-card-active::before {
content: '';
position: absolute;
inset: -2px;
border-radius: calc(0.75rem + 2px);
background: linear-gradient(135deg, #10b981, #14b8a6, #10b981);
z-index: -1;
opacity: 0.6;
transition: opacity 0.3s ease;
}
.class-card-active:hover::before {
opacity: 1;
}
}
+14 -10
View File
@@ -827,7 +827,7 @@ export default function AppLayout() {
<Sidebar
variant="sidebar"
collapsible="icon"
className="border-r border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-b from-emerald-50/80 via-white to-white dark:from-gray-900 dark:via-emerald-950/20 dark:to-gray-950"
className="border-r border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-b from-emerald-50/80 via-white to-emerald-50/30 dark:from-gray-900 dark:via-emerald-950/20 dark:to-gray-950"
>
<SidebarHeader className="p-4">
<motion.div
@@ -874,16 +874,20 @@ export default function AppLayout() {
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'
: '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 hover:translate-x-0.5'
}`}
>
{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" />
<motion.span
layoutId="sidebar-active-indicator"
className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500"
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
/>
)}
<item.icon className={`h-5 w-5 transition-colors duration-200 ${
<item.icon className={`h-5 w-5 transition-all 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'
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400 group-hover:scale-110'
}`} />
<span className="text-sm">{t(item.labelKey)}</span>
</SidebarMenuButton>
@@ -970,7 +974,7 @@ export default function AppLayout() {
<SidebarInset>
{/* Announcement Banner */}
<AnnouncementBanner schoolId={currentUser?.schoolId ?? null} />
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
<header className="header-gradient flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white/80 dark:bg-gray-950/80 backdrop-blur-xl px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10 transition-all duration-300">
<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">
@@ -985,7 +989,7 @@ export default function AppLayout() {
</BreadcrumbItem>
<BreadcrumbSeparator><ChevronRight className="h-3 w-3 text-emerald-400/60" /></BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="font-semibold text-gray-900 dark:text-gray-100 truncate">
<BreadcrumbPage className="breadcrumb-active-view truncate">
{breadcrumbLabel}
</BreadcrumbPage>
</BreadcrumbItem>
@@ -1066,7 +1070,7 @@ export default function AppLayout() {
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
className="animate-badge-pulse absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-0.5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-white text-[9px] font-bold flex items-center justify-center shadow-sm shadow-emerald-300/30"
>
{notifData.unreadCount > 9 ? '9+' : notifData.unreadCount}
</motion.span>
@@ -1207,7 +1211,7 @@ export default function AppLayout() {
</div>
</header>
<div className="flex-1 p-4 md:p-6 bg-white dark:bg-gray-950">
<div className="flex-1 p-4 md:p-6 bg-gradient-to-br from-white via-white to-emerald-50/20 dark:from-gray-950 dark:via-gray-950 dark:to-emerald-950/10">
<AnimatePresence mode="wait">
<motion.div
key={currentView}
@@ -1221,7 +1225,7 @@ export default function AppLayout() {
</AnimatePresence>
</div>
<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">
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white via-emerald-50/20 to-white dark:from-gray-950 dark:via-emerald-950/10 dark:to-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">
<Leaf className="h-3 w-3 text-emerald-500" />
+208 -9
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Cell, ResponsiveContainer, ReferenceLine, Tooltip as RechartsTooltip,
@@ -8,6 +8,7 @@ import {
import {
ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home,
Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles,
PenTool, GripVertical, ChevronLeft, ChevronRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -18,6 +19,8 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import DrawingCanvas from '@/components/drawing-canvas';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
@@ -107,9 +110,9 @@ const relativeDate = (dateStr: string) => {
};
const resultCountBadge = (count: number) => {
if (count === 0) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
if (count < 5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
if (count === 0) return 'grade-badge-below';
if (count < 5) return 'grade-badge-average';
return 'grade-badge-excellent';
};
// Emerald gradient colors for score distribution bars
@@ -255,6 +258,105 @@ export default function AssessmentsView() {
gradingAssessment?.maxScore ?? null
);
// Scratch pad state
const [scratchPadOpen, setScratchPadOpen] = useState(false);
const [scratchPadWidth, setScratchPadWidth] = useState(480);
const [scratchPadDrawingData, setScratchPadDrawingData] = useState<string | null>(null);
const [scratchPadDrawingId, setScratchPadDrawingId] = useState<string | null>(null);
const [scratchPadSaving, setScratchPadSaving] = useState(false);
const scratchPadResizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
// Load existing scratch pad drawing for the current assessment
const loadScratchPadDrawing = useCallback(async (assessmentId: string) => {
try {
const res = await fetch(`/api/drawings?subjectId=${assessmentId}`);
if (res.ok) {
const drawings = await res.json();
// Find a scratch pad drawing for this assessment
const scratchPad = drawings.find((d: { title: string; description: string }) =>
d.title?.startsWith('Scratch Pad:') || d.description?.startsWith('assessment-scratch-pad:')
);
if (scratchPad) {
setScratchPadDrawingId(scratchPad.id);
setScratchPadDrawingData(scratchPad.drawingData);
} else {
setScratchPadDrawingId(null);
setScratchPadDrawingData(null);
}
}
} catch {
// ignore
}
}, []);
// Save scratch pad drawing
const handleSaveScratchPad = useCallback(async (drawingData: string, imageData: string) => {
if (!gradingAssessment) return;
setScratchPadSaving(true);
try {
const title = `Scratch Pad: ${gradingAssessment.title}`;
const description = `assessment-scratch-pad:${gradingAssessment.id}`;
if (scratchPadDrawingId) {
// Update existing drawing
await fetch(`/api/drawings/${scratchPadDrawingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ drawingData, imageData, title }),
});
} else {
// Create new drawing
const res = await fetch('/api/drawings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
drawingData,
imageData,
subjectId: gradingAssessment.subjectId ?? undefined,
classGroupId: gradingAssessment.classGroupId ?? undefined,
schoolId: currentUser?.schoolId ?? undefined,
isPublic: false,
}),
});
if (res.ok) {
const created = await res.json();
setScratchPadDrawingId(created.id);
}
}
toast.success(t('assessments.scratch_pad_saved'));
} catch {
toast.error(t('error.generic'));
} finally {
setScratchPadSaving(false);
}
}, [gradingAssessment, scratchPadDrawingId, currentUser?.schoolId]);
// Handle mouse move for resizing
const handleResizeMouseMove = useCallback((e: MouseEvent) => {
if (!scratchPadResizeRef.current) return;
const delta = scratchPadResizeRef.current.startX - e.clientX;
const newWidth = Math.min(Math.max(scratchPadResizeRef.current.startWidth + delta, 320), window.innerWidth * 0.7);
setScratchPadWidth(newWidth);
}, []);
const handleResizeMouseUp = useCallback(() => {
scratchPadResizeRef.current = null;
document.removeEventListener('mousemove', handleResizeMouseMove);
document.removeEventListener('mouseup', handleResizeMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, [handleResizeMouseMove]);
const startResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
scratchPadResizeRef.current = { startX: e.clientX, startWidth: scratchPadWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleResizeMouseMove);
document.addEventListener('mouseup', handleResizeMouseUp);
}, [scratchPadWidth, handleResizeMouseMove, handleResizeMouseUp]);
useEffect(() => {
async function load() {
try {
@@ -344,6 +446,9 @@ export default function AssessmentsView() {
const openGrading = async (assessment: Assessment) => {
setGradingAssessment(assessment);
setGradingOpen(true);
setScratchPadOpen(false);
setScratchPadDrawingId(null);
setScratchPadDrawingData(null);
try {
const results = await fetchAssessmentResults(assessment.id);
setGradingResults(results);
@@ -357,6 +462,8 @@ export default function AssessmentsView() {
};
}
setGradingScores(scoresMap);
// Load existing scratch pad drawing
loadScratchPadDrawing(assessment.id);
} catch {
setGradingResults([]);
}
@@ -486,7 +593,7 @@ export default function AssessmentsView() {
</Card>
</motion.div>
) : (
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<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">
@@ -580,10 +687,26 @@ export default function AssessmentsView() {
</div>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700" onClick={() => openGrading(a)}>
<PenLine className="h-3 w-3 mr-1" />
{t('assessments.enter_scores')}
</Button>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
className="min-h-[44px] min-w-[44px] rounded-xl text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
onClick={() => {
setGradingAssessment(a);
setScratchPadOpen(true);
loadScratchPadDrawing(a.id);
}}
title={t('assessments.scratch_pad_open')}
>
<PenTool className="h-4 w-4" />
<span className="sr-only sm:not-sr-only sm:ml-1.5 text-xs">{t('assessments.scratch_pad')}</span>
</Button>
<Button size="sm" variant="outline" className="rounded-xl border-emerald-300 dark:border-emerald-700 min-h-[44px] min-w-[44px]" onClick={() => openGrading(a)}>
<PenLine className="h-3 w-3 mr-1" />
{t('assessments.enter_scores')}
</Button>
</div>
</TableCell>
</TableRow>
);
@@ -685,6 +808,15 @@ export default function AssessmentsView() {
<BarChart3 className="h-3 w-3" />
{t('label.max_score')}: <strong>{gradingAssessment.maxScore ?? '—'}</strong>
</span>
<Button
variant="outline"
size="sm"
className="ml-auto min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
onClick={() => setScratchPadOpen(true)}
>
<PenTool className="h-4 w-4 mr-1.5" />
{t('assessments.scratch_pad')}
</Button>
</DialogDescription>
)}
</DialogHeader>
@@ -792,6 +924,73 @@ export default function AssessmentsView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Scratch Pad Sheet */}
<Sheet open={scratchPadOpen} onOpenChange={setScratchPadOpen}>
<SheetContent
side="right"
className="p-0 gap-0 w-auto sm:max-w-none flex flex-col"
style={{ width: scratchPadWidth }}
>
{/* Resize handle */}
<div
className="absolute top-0 left-0 bottom-0 w-2 cursor-col-resize hover:bg-emerald-200/40 dark:hover:bg-emerald-700/40 active:bg-emerald-300/60 dark:active:bg-emerald-600/60 transition-colors z-10 flex items-center justify-center"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label={t('assessments.scratch_pad_resize')}
tabIndex={0}
>
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<SheetHeader className="px-6 pt-4 pb-2 border-b border-emerald-200/30 dark:border-emerald-900/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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">
<PenTool className="h-4 w-4" />
</div>
<SheetTitle className="text-base font-semibold">
{t('assessments.scratch_pad_title', { title: gradingAssessment?.title ?? '' })}
</SheetTitle>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="min-h-[44px] min-w-[44px] rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300"
onClick={() => {
if (scratchPadWidth <= 480) setScratchPadWidth(640);
else if (scratchPadWidth <= 640) setScratchPadWidth(860);
else setScratchPadWidth(480);
}}
title={t('assessments.scratch_pad_resize')}
>
{scratchPadWidth <= 480 ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
</div>
<SheetDescription className="text-xs text-muted-foreground mt-1">
{t('assessments.scratch_pad_description')}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">
<DrawingCanvas
backgroundType="lined"
initialDrawingData={scratchPadDrawingData ?? undefined}
onSave={handleSaveScratchPad}
title={`Scratch Pad: ${gradingAssessment?.title ?? ''}`}
/>
</div>
{scratchPadSaving && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 text-sm shadow-lg">
<div className="h-4 w-4 border-2 border-emerald-600 dark:border-emerald-400 border-t-transparent rounded-full animate-spin" />
{t('assessments.scratch_pad_saving')}
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
+3 -3
View File
@@ -863,7 +863,7 @@ export default function ClassesView() {
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
onClick={() => handleSelectClass(cls)}
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-hover-lift ${
className={`w-full text-left rounded-xl transition-all duration-200 border-l-3 ${schoolTypeAccent[cls.schoolType] ?? 'border-l-emerald-500'} bg-gradient-to-r ${schoolTypeGradient[cls.schoolType] ?? schoolTypeGradient.OTHER} hover:shadow-xl hover:shadow-emerald-500/8 card-shadow-transition ${
selectedClass?.id === cls.id
? 'shadow-lg ring-2 ring-emerald-300/60 dark:ring-emerald-700/50'
: 'hover:shadow-lg hover:shadow-emerald-200/30 dark:hover:shadow-emerald-900/20'
@@ -900,7 +900,7 @@ export default function ClassesView() {
{/* Small progress indicator */}
<div className="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
className="progress-bar-animated-fill h-full rounded-full bg-gradient-to-r from-emerald-400 to-teal-400 transition-all"
style={{ width: `${progressPercent(cls)}%` }}
/>
</div>
@@ -1459,7 +1459,7 @@ export default function ClassesView() {
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: idx * 0.03, duration: 0.3 }}
className="flex flex-col items-center gap-1 cursor-pointer"
className="animate-student-appear flex flex-col items-center gap-1 cursor-pointer"
onClick={() => {
useAppStore.getState().navigateToStudentDetail(s.id, 'classes');
}}
+5 -5
View File
@@ -561,7 +561,7 @@ export default function CompetenceFlowerView() {
</Collapsible>
{/* Radar chart */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: primaryColor, boxShadow: `0 2px 16px ${primaryColor}15, 0 1px 3px rgba(0,0,0,0.06)` }}>
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2">
@@ -786,7 +786,7 @@ export default function CompetenceFlowerView() {
</motion.div>
{/* Breakdown table */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 overflow-hidden" style={{ borderLeftColor: lightenColor(primaryColor, 0.1) }}>
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg text-white" style={{ backgroundColor: lightenColor(primaryColor, 0.1) }}>
@@ -861,7 +861,7 @@ export default function CompetenceFlowerView() {
</Card>
{/* Progress over time */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
@@ -896,7 +896,7 @@ export default function CompetenceFlowerView() {
{/* Comparison + strengths/weaknesses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Comparison card */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold 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">
@@ -963,7 +963,7 @@ export default function CompetenceFlowerView() {
</Card>
{/* Strengths & weaknesses */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<Card className="card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<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">
+747 -5
View File
@@ -48,6 +48,22 @@ import {
Theater,
ShoppingBag,
Tag,
Wallet,
Package,
Coins,
CreditCard,
Headphones,
Tv,
MonitorPlay,
Store,
Palette,
Coffee,
Armchair,
PencilRuler,
Sticker,
Notebook,
History,
Loader2,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -84,6 +100,7 @@ import { useAppStore, type CurrentUser } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchCompetitions,
fetchPublicCompetitions,
fetchCompetition,
createCompetition,
updateCompetition,
@@ -101,6 +118,63 @@ import {
} from '@/lib/api';
import { toast } from 'sonner';
/* ── Types for Digital Reward Catalog ──────────────────────────────── */
interface CatalogReward {
id: string;
title: string;
description?: string | null;
category: string;
pointsCost: number;
image?: string | null;
stock?: number | null;
isActive: boolean;
isDemo: boolean;
createdAt: string;
_count?: { redemptions: number };
}
interface PointsHistoryEntry {
id: string;
points: number;
source: string;
description?: string | null;
createdAt: string;
}
interface RedemptionEntry {
id: string;
rewardId: string;
pointsSpent: number;
status: string;
note?: string | null;
createdAt: string;
reward?: { title: string; category: string };
}
/* ── Constants ─────────────────────────────────────────────────────── */
const REWARD_CATEGORIES = ['streaming', 'shopping', 'experience', 'merchandise', 'privilege'] as const;
const DEMO_REWARDS: CatalogReward[] = [
{ id: 'demo-netflix', title: 'Netflix-Gutschein', description: '15 EUR Netflix-Gutschein', category: 'streaming', pointsCost: 500, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-spotify', title: 'Spotify Premium', description: '3 Monate Spotify Premium', category: 'streaming', pointsCost: 350, stock: 15, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-disney', title: 'Disney+-Gutschein', description: '1 Monat Disney+', category: 'streaming', pointsCost: 300, stock: 8, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-amazon', title: 'Amazon-Gutschein', description: '10 EUR Amazon-Gutschein', category: 'shopping', pointsCost: 400, stock: 20, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-thalia', title: 'Thalia-Gutschein', description: '10 EUR Thalia-Gutschein', category: 'shopping', pointsCost: 400, stock: 12, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-mediamarkt', title: 'MediaMarkt-Gutschein', description: '15 EUR MediaMarkt-Gutschein', category: 'shopping', pointsCost: 550, stock: 5, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-cinema', title: 'Kino-Ticket', description: 'Ein Kinoticket nach Wahl', category: 'experience', pointsCost: 250, stock: 25, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-theater', title: 'Theater-Ticket', description: 'Ein Theaterbesuch', category: 'experience', pointsCost: 300, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-concert', title: 'Konzert-Ticket', description: 'Konzert-Erlebnis', category: 'experience', pointsCost: 600, stock: 5, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-museum', title: 'Museumseintritt', description: 'Freier Eintritt ins Museum', category: 'experience', pointsCost: 150, stock: 30, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-tshirt', title: 'Schul-T-Shirt', description: 'Offizielles Schul-T-Shirt', category: 'merchandise', pointsCost: 200, stock: 50, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-stickers', title: 'Aufkleber-Set', description: 'Schul-Sticker-Set', category: 'merchandise', pointsCost: 50, stock: 100, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-notebook', title: 'Schulheft', description: 'Schulheft mit Schullogo', category: 'merchandise', pointsCost: 75, stock: 80, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-homework', title: 'Hausaufgaben-Frei', description: 'Eine Hausaufgabe aussetzen', category: 'privilege', pointsCost: 150, stock: 20, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-break', title: 'Extra Pause', description: '5 Minuten extra Pause', category: 'privilege', pointsCost: 100, stock: 30, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
{ id: 'demo-seat', title: 'Sitzplatz wählen', description: 'Sitzplatz für eine Woche frei wählen', category: 'privilege', pointsCost: 200, stock: 10, isActive: true, isDemo: true, createdAt: new Date().toISOString() },
];
/* ── Helpers ─────────────────────────────────────────────────────── */
const COMPETITION_TYPES = ['class', 'inter_class', 'inter_school'] as const;
@@ -234,6 +308,92 @@ function getScoringLabel(type: string): string {
}
}
function getCategoryLabelReward(category: string): string {
switch (category) {
case 'streaming': return t('rewards.category.streaming');
case 'shopping': return t('rewards.category.shopping');
case 'experience': return t('rewards.category.experience');
case 'merchandise': return t('rewards.category.merchandise');
case 'privilege': return t('rewards.category.privilege');
default: return category;
}
}
function getCategoryIcon(category: string, className?: string) {
switch (category) {
case 'streaming': return <Tv className={className} />;
case 'shopping': return <ShoppingBag className={className} />;
case 'experience': return <Ticket className={className} />;
case 'merchandise': return <Package className={className} />;
case 'privilege': return <Crown className={className} />;
default: return <Gift className={className} />;
}
}
function getCategoryColor(category: string): string {
switch (category) {
case 'streaming': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
case 'shopping': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
case 'experience': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300';
case 'merchandise': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
case 'privilege': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
}
function getCategoryBorderColor(category: string): string {
switch (category) {
case 'streaming': return 'border-l-rose-400';
case 'shopping': return 'border-l-amber-400';
case 'experience': return 'border-l-violet-400';
case 'merchandise': return 'border-l-teal-400';
case 'privilege': return 'border-l-emerald-400';
default: return 'border-l-gray-400';
}
}
function getCategoryBgGradient(category: string): string {
switch (category) {
case 'streaming': return 'from-rose-50/50 dark:from-rose-900/10';
case 'shopping': return 'from-amber-50/50 dark:from-amber-900/10';
case 'experience': return 'from-violet-50/50 dark:from-violet-900/10';
case 'merchandise': return 'from-teal-50/50 dark:from-teal-900/10';
case 'privilege': return 'from-emerald-50/50 dark:from-emerald-900/10';
default: return 'from-gray-50/50 dark:from-gray-800/50';
}
}
function getRedemptionStatusLabel(status: string): string {
switch (status) {
case 'pending': return t('rewards.redemption.pending');
case 'approved': return t('rewards.redemption.approved');
case 'rejected': return t('rewards.redemption.rejected');
case 'fulfilled': return t('rewards.redemption.fulfilled');
default: return status;
}
}
function getRedemptionStatusColor(status: string): string {
switch (status) {
case 'pending': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
case 'approved': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
case 'rejected': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
case 'fulfilled': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
}
}
function getPointsSourceLabel(source: string): string {
switch (source) {
case 'competition': return t('rewards.points_source.competition');
case 'grade': return t('rewards.points_source.grade');
case 'attendance': return t('rewards.points_source.attendance');
case 'homework': return t('rewards.points_source.homework');
case 'bonus': return t('rewards.points_source.bonus');
default: return source;
}
}
function getTimeProgress(startDate: string, endDate: string): number {
const now = Date.now();
const start = new Date(startDate).getTime();
@@ -585,6 +745,28 @@ export default function CompetitionsView() {
const [myClaims, setMyClaims] = useState<RewardClaimData[]>([]);
const [claimingRewardId, setClaimingRewardId] = useState<string | null>(null);
// Digital Reward Catalog
const [catalogRewards, setCatalogRewards] = useState<CatalogReward[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogCategory, setCatalogCategory] = useState<string>('all');
const [pointsBalance, setPointsBalance] = useState(0);
const [pointsEarned, setPointsEarned] = useState(0);
const [pointsSpent, setPointsSpent] = useState(0);
const [pointsHistory, setPointsHistory] = useState<PointsHistoryEntry[]>([]);
const [myRedemptions, setMyRedemptions] = useState<RedemptionEntry[]>([]);
const [redeemDialogOpen, setRedeemDialogOpen] = useState(false);
const [redeemTarget, setRedeemTarget] = useState<CatalogReward | null>(null);
const [isRedeeming, setIsRedeeming] = useState(false);
const [createRewardOpen, setCreateRewardOpen] = useState(false);
const [newRewardForm, setNewRewardForm] = useState({
title: '',
description: '',
category: 'streaming',
pointsCost: '',
stock: '',
});
const [isCreatingReward, setIsCreatingReward] = useState(false);
// Register
const [isRegistering, setIsRegistering] = useState(false);
@@ -596,11 +778,16 @@ export default function CompetitionsView() {
/* ── Fetch competitions ──────────────────────────────────────── */
const loadCompetitions = useCallback(async () => {
if (!schoolId) return;
setIsLoading(true);
try {
const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100);
setCompetitions(result.competitions);
if (schoolId) {
const result = await fetchCompetitions(schoolId, undefined, undefined, undefined, 100);
setCompetitions(result.competitions);
} else {
// Users without a schoolId can still see public competitions
const result = await fetchPublicCompetitions(100);
setCompetitions(result.competitions);
}
} catch (err) {
console.error('Failed to load competitions:', err);
toast.error('Failed to load competitions');
@@ -615,9 +802,8 @@ export default function CompetitionsView() {
/* ── Fetch my claims ─────────────────────────────────────────── */
const loadMyClaims = useCallback(async () => {
if (!schoolId) return;
try {
const result = await fetchRewardClaims(schoolId, undefined, undefined);
const result = await fetchRewardClaims(schoolId ?? undefined, undefined, undefined);
setMyClaims(result.claims.filter((c) => c.userId === currentUser?.id));
} catch {
// silently fail
@@ -628,6 +814,130 @@ export default function CompetitionsView() {
loadMyClaims();
}, [loadMyClaims]);
/* ── Fetch reward catalog & points ───────────────────────────── */
const loadCatalogData = useCallback(async () => {
setCatalogLoading(true);
try {
const [rewardsRes, pointsRes] = await Promise.all([
fetch('/api/rewards').catch(() => null),
fetch('/api/reward-points').catch(() => null),
]);
if (rewardsRes?.ok) {
const rewardsData = await rewardsRes.json();
if (Array.isArray(rewardsData) && rewardsData.length > 0) {
setCatalogRewards(rewardsData);
} else {
// Use demo rewards when no real rewards exist
setCatalogRewards(DEMO_REWARDS);
}
} else {
setCatalogRewards(DEMO_REWARDS);
}
if (pointsRes?.ok) {
const pointsData = await pointsRes.json();
setPointsBalance(pointsData.balance ?? 0);
setPointsEarned(pointsData.totalEarned ?? 0);
setPointsSpent(pointsData.totalSpent ?? 0);
setPointsHistory(pointsData.pointsHistory ?? []);
setMyRedemptions(pointsData.redemptions ?? []);
} else {
// Demo data
setPointsBalance(750);
setPointsEarned(1000);
setPointsSpent(250);
setPointsHistory([
{ id: 'ph1', points: 200, source: 'competition', description: 'Wettbewerb gewonnen', createdAt: new Date(Date.now() - 86400000).toISOString() },
{ id: 'ph2', points: 100, source: 'attendance', description: '7 Tage Anwesenheitssträhne', createdAt: new Date(Date.now() - 172800000).toISOString() },
{ id: 'ph3', points: 50, source: 'homework', description: 'Hausaufgaben pünktlich abgegeben', createdAt: new Date(Date.now() - 259200000).toISOString() },
{ id: 'ph4', points: 150, source: 'grade', description: 'Sehr gut in Mathematik', createdAt: new Date(Date.now() - 345600000).toISOString() },
{ id: 'ph5', points: 500, source: 'bonus', description: 'Klassensprecher', createdAt: new Date(Date.now() - 432000000).toISOString() },
]);
setMyRedemptions([]);
}
} catch {
setCatalogRewards(DEMO_REWARDS);
setPointsBalance(750);
setPointsEarned(1000);
setPointsSpent(250);
} finally {
setCatalogLoading(false);
}
}, []);
useEffect(() => {
loadCatalogData();
}, [loadCatalogData]);
/* ── Redeem a reward ─────────────────────────────────────────── */
const handleRedeemReward = useCallback(async () => {
if (!redeemTarget) return;
setIsRedeeming(true);
try {
const res = await fetch('/api/rewards/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rewardId: redeemTarget.id }),
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'Insufficient points') {
toast.error(t('rewards.insufficient_points'));
} else if (data.error === 'Out of stock') {
toast.error(t('rewards.out_of_stock'));
} else {
toast.error(data.error || t('rewards.redeem_error'));
}
return;
}
toast.success(t('rewards.redeem_success'));
setRedeemDialogOpen(false);
setRedeemTarget(null);
loadCatalogData();
} catch {
toast.error(t('rewards.redeem_error'));
} finally {
setIsRedeeming(false);
}
}, [redeemTarget, loadCatalogData]);
/* ── Create a new reward (admin) ─────────────────────────────── */
const handleCreateReward = useCallback(async () => {
if (!newRewardForm.title.trim() || !newRewardForm.pointsCost) {
toast.error('Title and points cost are required');
return;
}
setIsCreatingReward(true);
try {
const res = await fetch('/api/rewards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newRewardForm.title.trim(),
description: newRewardForm.description.trim() || null,
category: newRewardForm.category,
pointsCost: parseInt(newRewardForm.pointsCost, 10),
stock: newRewardForm.stock ? parseInt(newRewardForm.stock, 10) : null,
isDemo: true,
}),
});
if (!res.ok) {
const data = await res.json();
toast.error(data.error || t('rewards.redeem_error'));
return;
}
toast.success(t('rewards.create_reward'));
setCreateRewardOpen(false);
setNewRewardForm({ title: '', description: '', category: 'streaming', pointsCost: '', stock: '' });
loadCatalogData();
} catch {
toast.error(t('rewards.redeem_error'));
} finally {
setIsCreatingReward(false);
}
}, [newRewardForm, loadCatalogData]);
/* ── Filtered competitions ───────────────────────────────────── */
const filteredCompetitions = useMemo(() => {
let result = competitions;
@@ -917,6 +1227,7 @@ export default function CompetitionsView() {
<TabsTrigger value="my" className="min-h-[44px]">{t('competition.manage')}</TabsTrigger>
<TabsTrigger value="leaderboard" className="min-h-[44px]">{t('competition.leaderboard')}</TabsTrigger>
<TabsTrigger value="rewards" className="min-h-[44px]">{t('competition.rewards')}</TabsTrigger>
<TabsTrigger value="catalog" className="min-h-[44px]">{t('rewards.title')}</TabsTrigger>
</TabsList>
{/* Overview Tab */}
@@ -1213,6 +1524,283 @@ export default function CompetitionsView() {
)}
</section>
</TabsContent>
{/* ── Digital Reward Catalog Tab ─────────────────────────────── */}
<TabsContent value="catalog" className="space-y-6 mt-4">
{/* Points Balance Header */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 shadow-lg">
<Wallet className="h-7 w-7 text-white" />
</div>
<div>
<p className="text-sm text-muted-foreground">{t('rewards.points_balance')}</p>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance}</p>
</div>
</div>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span className="text-muted-foreground">{t('rewards.points_earned')}:</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">{pointsEarned}</span>
</div>
<div className="flex items-center gap-2">
<CreditCard className="h-4 w-4 text-amber-500" />
<span className="text-muted-foreground">{t('rewards.points_spent')}:</span>
<span className="font-semibold text-amber-600 dark:text-amber-400">{pointsSpent}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Category Filter + Create Button */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-wrap gap-2">
<Button
variant={catalogCategory === 'all' ? 'default' : 'outline'}
size="sm"
className={`min-h-[40px] rounded-lg ${catalogCategory === 'all' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
onClick={() => setCatalogCategory('all')}
>
{t('rewards.category.all')}
</Button>
{REWARD_CATEGORIES.map((cat) => (
<Button
key={cat}
variant={catalogCategory === cat ? 'default' : 'outline'}
size="sm"
className={`min-h-[40px] rounded-lg ${catalogCategory === cat ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
onClick={() => setCatalogCategory(cat)}
>
{getCategoryIcon(cat, 'h-4 w-4 mr-1')}
{getCategoryLabelReward(cat)}
</Button>
))}
</div>
{canCreate && (
<Button
className="min-h-[44px] bg-amber-600 hover:bg-amber-700"
onClick={() => setCreateRewardOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
{t('rewards.create_reward')}
</Button>
)}
</div>
{/* Reward Catalog Grid */}
{catalogLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}><CardContent className="p-4"><Skeleton className="h-40" /></CardContent></Card>
))}
</div>
) : (
<AnimatePresence>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{catalogRewards
.filter((r) => catalogCategory === 'all' || r.category === catalogCategory)
.map((reward, idx) => (
<motion.div
key={reward.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2, delay: idx * 0.03 }}
whileHover={{ y: -2 }}
className="h-full"
>
<Card className={`border-l-4 ${getCategoryBorderColor(reward.category)} h-full flex flex-col transition-shadow hover:shadow-md`}>
<CardHeader className={`pb-3 bg-gradient-to-r ${getCategoryBgGradient(reward.category)} to-transparent`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${getCategoryColor(reward.category)} shrink-0`}>
{getCategoryIcon(reward.category, 'h-5 w-5')}
</div>
<div className="min-w-0">
<CardTitle className="text-sm leading-tight truncate">{reward.title}</CardTitle>
<Badge className={`${getCategoryColor(reward.category)} text-xs mt-1`}>
{getCategoryLabelReward(reward.category)}
</Badge>
</div>
</div>
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-400 shrink-0">
<Coins className="h-4 w-4" />
<span className="font-bold text-sm">{reward.pointsCost}</span>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 pb-3 space-y-3">
{reward.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{reward.description}</p>
)}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{reward.stock !== null && reward.stock !== undefined
? t('rewards.stock_remaining').replace('{count}', String(reward.stock))
: t('rewards.unlimited')}
</span>
{pointsBalance >= reward.pointsCost ? (
<span className="text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
</span>
) : (
<span className="text-rose-500 dark:text-rose-400 flex items-center gap-1">
<XCircle className="h-3 w-3" />
</span>
)}
</div>
</CardContent>
<CardFooter className="pt-0 pb-4">
<Button
size="sm"
className={`w-full min-h-[40px] rounded-lg ${
pointsBalance >= reward.pointsCost
? 'bg-emerald-600 hover:bg-emerald-700'
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed'
}`}
disabled={pointsBalance < reward.pointsCost}
onClick={() => {
setRedeemTarget(reward);
setRedeemDialogOpen(true);
}}
>
<Gift className="h-4 w-4 mr-1" />
{t('rewards.redeem')}
</Button>
</CardFooter>
</Card>
</motion.div>
))}
</div>
</AnimatePresence>
)}
{/* Earn Points Section */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2 text-base">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<Zap className="h-4 w-4" />
</div>
{t('rewards.earn_points')}
</CardTitle>
<CardDescription>{t('rewards.earn_points_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{[
{ icon: Trophy, label: t('rewards.earn_competition'), color: 'emerald' },
{ icon: Star, label: t('rewards.earn_grades'), color: 'amber' },
{ icon: Calendar, label: t('rewards.earn_attendance'), color: 'teal' },
{ icon: BookOpen, label: t('rewards.earn_homework'), color: 'violet' },
{ icon: Sparkles, label: t('rewards.earn_bonus'), color: 'rose' },
].map((item) => {
const Icon = item.icon;
const colorMap: Record<string, { bg: string; text: string }> = {
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400' },
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400' },
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400' },
rose: { bg: 'bg-rose-100 dark:bg-rose-900/30', text: 'text-rose-600 dark:text-rose-400' },
};
const c = colorMap[item.color] || colorMap.emerald;
return (
<div key={item.label} className={`p-3 rounded-xl ${c.bg}`}>
<Icon className={`h-5 w-5 ${c.text} mb-2`} />
<p className="text-xs font-medium text-gray-700 dark:text-gray-300">{item.label}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Points History & My Redemptions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Points History */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2 text-base">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<History className="h-4 w-4" />
</div>
{t('rewards.points_history')}
</CardTitle>
</CardHeader>
<CardContent className="max-h-96 overflow-y-auto">
{pointsHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<History className="h-8 w-8 mb-2 opacity-30" />
<p className="text-xs">{t('rewards.no_rewards')}</p>
</div>
) : (
<div className="space-y-2">
{pointsHistory.map((entry) => (
<div key={entry.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-100 dark:bg-emerald-900/30 shrink-0">
<Plus className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium truncate">{entry.description || getPointsSourceLabel(entry.source)}</p>
<p className="text-xs text-muted-foreground">{formatDate(entry.createdAt)}</p>
</div>
</div>
<span className="text-sm font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">+{entry.points}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* My Redemptions */}
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2 text-base">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<Gift className="h-4 w-4" />
</div>
{t('rewards.my_redemptions')}
</CardTitle>
</CardHeader>
<CardContent className="max-h-96 overflow-y-auto">
{myRedemptions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Gift className="h-8 w-8 mb-2 opacity-30" />
<p className="text-xs">{t('rewards.no_rewards')}</p>
</div>
) : (
<div className="space-y-2">
{myRedemptions.map((redemption) => (
<div key={redemption.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-amber-100 dark:bg-amber-900/30 shrink-0">
{getCategoryIcon(redemption.reward?.category ?? 'streaming', 'h-3 w-3 text-amber-600 dark:text-amber-400')}
</div>
<div className="min-w-0">
<p className="text-xs font-medium truncate">{redemption.reward?.title ?? 'Reward'}</p>
<p className="text-xs text-muted-foreground">{formatDate(redemption.createdAt)}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className={`${getRedemptionStatusColor(redemption.status)} text-xs`}>
{getRedemptionStatusLabel(redemption.status)}
</Badge>
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">-{redemption.pointsSpent}</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* ── Competition Detail Dialog ──────────────────────────── */}
@@ -1716,6 +2304,160 @@ export default function CompetitionsView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Redeem Reward Dialog ──────────────────────────────────── */}
<Dialog open={redeemDialogOpen} onOpenChange={setRedeemDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Gift className="h-5 w-5 text-amber-500" />
{t('rewards.redeem_confirm')}
</DialogTitle>
<DialogDescription>
{redeemTarget
? t('rewards.redeem_confirm_desc')
.replace('{points}', String(redeemTarget.pointsCost))
.replace('{reward}', redeemTarget.title)
: ''}
</DialogDescription>
</DialogHeader>
{redeemTarget && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${getCategoryColor(redeemTarget.category)} shrink-0`}>
{getCategoryIcon(redeemTarget.category, 'h-6 w-6')}
</div>
<div>
<p className="font-semibold">{redeemTarget.title}</p>
<div className="flex items-center gap-2 mt-1">
<Badge className={getCategoryColor(redeemTarget.category)}>
{getCategoryLabelReward(redeemTarget.category)}
</Badge>
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<Coins className="h-3 w-3" />
{redeemTarget.pointsCost}
</span>
</div>
</div>
</div>
<div className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<span className="text-sm text-muted-foreground">{t('rewards.points_balance')}</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance}</span>
</div>
{pointsBalance >= redeemTarget.pointsCost && (
<div className="flex items-center justify-between p-3 rounded-xl bg-emerald-50/50 dark:bg-emerald-900/10">
<span className="text-sm text-muted-foreground">After redemption:</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">{pointsBalance - redeemTarget.pointsCost}</span>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" className="min-h-[44px]" onClick={() => setRedeemDialogOpen(false)}>
{t('action.cancel')}
</Button>
<Button
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
onClick={handleRedeemReward}
disabled={isRedeeming || !redeemTarget || pointsBalance < (redeemTarget?.pointsCost ?? Infinity)}
>
{isRedeeming ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Gift className="h-4 w-4 mr-2" />
)}
{t('rewards.redeem')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Create Reward Dialog ──────────────────────────────────── */}
<Dialog open={createRewardOpen} onOpenChange={setCreateRewardOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5 text-amber-500" />
{t('rewards.create_reward')}
</DialogTitle>
<DialogDescription>
{t('rewards.create_reward')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<Label>{t('competition.name')} *</Label>
<Input
value={newRewardForm.title}
onChange={(e) => setNewRewardForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Reward title"
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.description')}</Label>
<Input
value={newRewardForm.description}
onChange={(e) => setNewRewardForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Description..."
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.category')}</Label>
<Select value={newRewardForm.category} onValueChange={(v) => setNewRewardForm((f) => ({ ...f, category: v }))}>
<SelectTrigger className="mt-1 min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REWARD_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>{getCategoryLabelReward(cat)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('rewards.points_cost')} *</Label>
<Input
type="number"
value={newRewardForm.pointsCost}
onChange={(e) => setNewRewardForm((f) => ({ ...f, pointsCost: e.target.value }))}
placeholder="100"
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>Stock</Label>
<Input
type="number"
value={newRewardForm.stock}
onChange={(e) => setNewRewardForm((f) => ({ ...f, stock: e.target.value }))}
placeholder="Unlimited"
className="mt-1 min-h-[44px]"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="min-h-[44px]" onClick={() => setCreateRewardOpen(false)}>
{t('action.cancel')}
</Button>
<Button
className="min-h-[44px] bg-amber-600 hover:bg-amber-700"
onClick={handleCreateReward}
disabled={isCreatingReward}
>
{isCreatingReward ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{t('rewards.create_reward')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+697 -3
View File
@@ -64,6 +64,19 @@ import {
PenSquare,
Eye,
Tag,
Shield,
Database,
Server,
Cpu,
UserPlus,
School,
LayoutList,
Activity as ActivityIcon,
CheckCircle2,
AlertCircle,
TrendingDown,
BarChart2,
PieChart as PieChartIcon,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -74,6 +87,7 @@ import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import {
Select,
SelectContent,
@@ -91,6 +105,24 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import {
Tooltip,
TooltipContent,
@@ -476,6 +508,499 @@ function ParentDashboard({ currentUser, setCurrentView, locale }: {
</CardContent>
</Card>
</motion.div>
{/* Parent Children's Recent Grades */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<Calculator className="h-4 w-4" />
</div>
{t('parent.children_grades')}
</CardTitle>
</CardHeader>
<CardContent>
{children.length > 0 ? (
<div className="space-y-3">
{children.map((child) => (
<div key={child.studentId} className="p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-violet-400 to-purple-500 text-white text-[10px] font-bold shrink-0">
{child.firstName[0]}{child.lastName[0]}
</div>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{child.firstName} {child.lastName}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 ml-auto">{child.className}</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
{[
{ subject: t('student.notebook_math'), grade: '2.3' },
{ subject: t('student.notebook_german'), grade: '1.8' },
{ subject: t('student.notebook_english'), grade: '2.7' },
{ subject: t('student.notebook_science'), grade: '2.1' },
].map((g) => (
<div key={g.subject} className="px-2 py-1.5 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100/50 dark:border-emerald-900/20 text-center">
<p className="text-[10px] text-gray-500 dark:text-gray-400 truncate">{g.subject}</p>
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{g.grade}</p>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-24 rounded-lg bg-gray-50 dark:bg-gray-900/20 border border-gray-200/30 dark:border-gray-800/20">
<Calculator className="h-8 w-8 text-gray-300 dark:text-gray-600 mb-1" />
<p className="text-xs text-gray-400 dark:text-gray-500">{t('parent.no_children')}</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Parent Competition Results & Calendar Events */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Competition Results */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
<Trophy className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.competition_results')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-amber-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex items-center justify-center h-24 rounded-lg bg-gradient-to-br from-amber-50 to-yellow-50/50 dark:from-amber-900/20 dark:to-yellow-900/10 border border-amber-200/30 dark:border-amber-900/20">
<div className="text-center">
<Trophy className="h-8 w-8 text-amber-400/60 dark:text-amber-600/40 mx-auto mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent.no_competition_results')}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Calendar Events */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full cursor-pointer min-h-[44px]" onClick={() => setCurrentView('calendar')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-purple-500 text-white shadow-sm">
<CalendarIconNav className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('parent.calendar_events')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-2">
{[
{ title: locale === 'de' ? 'Elternsprechtag' : 'Parent-Teacher Day', date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), color: 'from-emerald-400 to-teal-500' },
{ title: locale === 'de' ? 'Schulfest' : 'School Festival', date: new Date(Date.now() + 12 * 24 * 60 * 60 * 1000), color: 'from-amber-400 to-orange-500' },
{ title: locale === 'de' ? 'Zeugnisausgabe' : 'Report Card Day', date: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000), color: 'from-violet-400 to-purple-500' },
].map((event) => {
const daysUntil = Math.ceil((event.date.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return (
<div key={event.title} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/50 dark:border-gray-800/20 min-h-[44px]">
<div className={`flex items-center justify-center w-7 h-7 rounded-lg bg-gradient-to-br ${event.color} text-white shadow-sm shrink-0`}>
<CalendarDays className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">{event.title}</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500">{event.date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: 'numeric', month: 'short' })}</p>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">{daysUntil}d</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Parent Announcements */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
// ─── School Admin Dashboard Component ──────────────────────────────────
function SchoolAdminDashboard({ currentUser, setCurrentView, locale, data }: {
currentUser: CurrentUser;
setCurrentView: (view: ViewName) => void;
locale: string;
data: DashboardData;
}) {
// Mock audit log data
const auditLogEntries = [
{ id: '1', action: 'USER_LOGIN', user: 'Max Mustermann', timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(), details: 'Teacher login' },
{ id: '2', action: 'ASSESSMENT_CREATED', user: 'Anna Schmidt', timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), details: 'Math assessment created' },
{ id: '3', action: 'STUDENT_ENROLLED', user: 'System', timestamp: new Date(Date.now() - 1000 * 60 * 60).toISOString(), details: 'New student enrolled' },
{ id: '4', action: 'REPORT_GENERATED', user: 'Peter Weber', timestamp: new Date(Date.now() - 1000 * 60 * 120).toISOString(), details: 'Class report generated' },
{ id: '5', action: 'SETTINGS_UPDATED', user: 'Admin', timestamp: new Date(Date.now() - 1000 * 60 * 180).toISOString(), details: 'School settings updated' },
];
// Mock system health data
const systemHealth = {
status: 'healthy' as const,
uptime: '99.9%',
activeUsers: Math.floor(data.stats.totalStudents * 0.3 + data.stats.totalClasses * 2),
storageUsed: 42,
apiRequestsToday: 1247,
};
// Enrollment trend data for chart
const enrollmentData = [
{ month: locale === 'de' ? 'Jan' : 'Jan', students: Math.floor(data.stats.totalStudents * 0.85) },
{ month: locale === 'de' ? 'Feb' : 'Feb', students: Math.floor(data.stats.totalStudents * 0.87) },
{ month: locale === 'de' ? 'Mär' : 'Mar', students: Math.floor(data.stats.totalStudents * 0.9) },
{ month: locale === 'de' ? 'Apr' : 'Apr', students: Math.floor(data.stats.totalStudents * 0.92) },
{ month: locale === 'de' ? 'Mai' : 'May', students: Math.floor(data.stats.totalStudents * 0.95) },
{ month: locale === 'de' ? 'Jun' : 'Jun', students: data.stats.totalStudents },
];
// Grade distribution data for pie chart
const gradeDistribution = [
{ name: '1', value: 25, color: '#10b981' },
{ name: '2', value: 35, color: '#14b8a6' },
{ name: '3', value: 25, color: '#f59e0b' },
{ name: '4', value: 12, color: '#f97316' },
{ name: '5-6', value: 3, color: '#ef4444' },
];
const getActionIcon = (action: string) => {
switch (action) {
case 'USER_LOGIN': return UserCheck;
case 'ASSESSMENT_CREATED': return ClipboardCheck;
case 'STUDENT_ENROLLED': return UserPlus;
case 'REPORT_GENERATED': return FileText;
case 'SETTINGS_UPDATED': return Settings;
default: return Activity;
}
};
const getActionColor = (action: string) => {
switch (action) {
case 'USER_LOGIN': return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400';
case 'ASSESSMENT_CREATED': return 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400';
case 'STUDENT_ENROLLED': return 'bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400';
case 'REPORT_GENERATED': return 'bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400';
case 'SETTINGS_UPDATED': return 'bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400';
default: return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400';
}
};
const relativeTime = (dateStr: string) => {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return locale === 'de' ? 'Gerade eben' : 'Just now';
if (diffMins < 60) return locale === 'de' ? `Vor ${diffMins} Min.` : `${diffMins}m ago`;
if (diffHours < 24) return locale === 'de' ? `Vor ${diffHours} Std.` : `${diffHours}h ago`;
if (diffDays === 1) return t('date.yesterday');
if (diffDays < 7) return t('date.days_ago', { count: diffDays });
return date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { day: '2-digit', month: 'short' });
};
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-6 pb-8"
>
{/* Admin Welcome Header */}
<motion.div variants={itemVariants}>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3.5">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-br from-rose-400 to-orange-500 text-white shadow-lg shadow-rose-300/30 ring-2 ring-rose-200/30 dark:ring-rose-800/20">
<Shield className="w-6 h-6" />
</div>
<div>
<h2 className="text-2xl font-bold bg-gradient-to-r from-rose-600 via-orange-600 to-amber-500 dark:from-rose-400 dark:via-orange-400 dark:to-amber-300 bg-clip-text text-transparent">
{t('admin.welcome_back')}, {currentUser.firstName}!
</h2>
<p className="text-rose-600/60 dark:text-rose-400/40 mt-0.5 text-sm">{t('admin.dashboard_title')}</p>
</div>
</div>
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-rose-50 to-orange-50/50 dark:from-rose-900/20 dark:to-orange-900/10 border border-rose-200/40 dark:border-rose-900/30 text-xs font-medium text-rose-700 dark:text-rose-300 shadow-sm">
<CalendarDays className="h-3.5 w-3.5" />
<span className="font-semibold">
{new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
</motion.div>
{/* School Overview Stats */}
<motion.div variants={itemVariants} className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ icon: Users, label: t('admin.total_students'), value: data.stats.totalStudents, color: 'from-emerald-400 to-teal-500', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200/40 dark:border-emerald-900/30' },
{ icon: BarChart3, label: t('admin.total_classes'), value: data.stats.totalClasses, color: 'from-teal-400 to-cyan-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-200/40 dark:border-teal-900/30' },
{ icon: GraduationCap, label: t('admin.total_teachers'), value: Math.max(1, Math.floor(data.stats.totalClasses * 0.8)), color: 'from-amber-400 to-orange-500', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200/40 dark:border-amber-900/30' },
{ icon: Heart, label: t('admin.total_parents'), value: Math.floor(data.stats.totalStudents * 1.4), color: 'from-rose-400 to-pink-500', bg: 'bg-rose-50 dark:bg-rose-900/20', border: 'border-rose-200/40 dark:border-rose-900/30' },
].map((stat) => (
<motion.div
key={stat.label}
whileHover={{ scale: 1.03, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Card className={`border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] ${stat.bg} ${stat.border}`}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} text-white shadow-sm shrink-0`}>
<stat.icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{stat.label}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 leading-none mt-0.5"><CountUp target={stat.value} /></p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
{/* System Health Card */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<Server className="h-4 w-4" />
</div>
{t('admin.system_health')}
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs shadow-sm">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('admin.health_excellent')}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ icon: ActivityIcon, label: t('admin.uptime'), value: systemHealth.uptime, color: 'text-emerald-600 dark:text-emerald-400' },
{ icon: Users, label: t('admin.active_users'), value: systemHealth.activeUsers.toString(), color: 'text-teal-600 dark:text-teal-400' },
{ icon: Database, label: t('admin.storage_used'), value: `${systemHealth.storageUsed}%`, color: 'text-amber-600 dark:text-amber-400' },
{ icon: Cpu, label: t('admin.api_requests'), value: systemHealth.apiRequestsToday.toLocaleString(), color: 'text-violet-600 dark:text-violet-400' },
].map((metric) => (
<div key={metric.label} className="p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className="flex items-center gap-2 mb-1.5">
<metric.icon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
<span className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">{metric.label}</span>
</div>
<p className={`text-lg font-bold ${metric.color}`}>{metric.value}</p>
</div>
))}
</div>
{/* Storage usage bar */}
<div className="mt-4 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">{t('admin.storage_used')}</span>
<span className="text-xs font-semibold text-amber-600 dark:text-amber-400">{systemHealth.storageUsed}%</span>
</div>
<Progress value={systemHealth.storageUsed} className="h-2" />
</div>
</CardContent>
</Card>
</motion.div>
{/* School Statistics Charts */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm">
<BarChart2 className="h-4 w-4" />
</div>
{t('admin.school_statistics')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Enrollment Trend */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('admin.enrollment_trend')}</h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={enrollmentData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="#9ca3af" />
<YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" />
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
/>
<Bar dataKey="students" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Grade Distribution */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('admin.grade_distribution')}</h4>
<div className="flex items-center gap-4">
<ResponsiveContainer width="60%" height={200}>
<PieChart>
<Pie
data={gradeDistribution}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={3}
dataKey="value"
>
{gradeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
<div className="space-y-2">
{gradeDistribution.map((grade) => (
<div key={grade.name} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: grade.color }} />
<span className="text-xs text-gray-600 dark:text-gray-400">{grade.name}: {grade.value}%</span>
</div>
))}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Audit Log & User Management */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Audit Log */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<LayoutList className="h-4 w-4" />
</div>
{t('admin.audit_log')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('admin.audit_log_desc')}</span>
</CardTitle>
</CardHeader>
<CardContent>
{auditLogEntries.length === 0 ? (
<div className="text-center py-8">
<LayoutList className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('admin.no_audit_entries')}</p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto scrollbar-education pr-1">
{auditLogEntries.map((entry) => {
const ActionIcon = getActionIcon(entry.action);
return (
<div key={entry.id} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${getActionColor(entry.action)} shrink-0`}>
<ActionIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{entry.details}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{entry.user} · {relativeTime(entry.timestamp)}
</p>
</div>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">{entry.action.replace('_', ' ')}</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* User Management Quick Actions */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] h-full">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<UserPlus className="h-4 w-4" />
</div>
{t('admin.user_management')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ icon: GraduationCap, label: t('admin.manage_teachers'), desc: t('admin.add_teacher'), color: 'from-amber-400 to-orange-500', view: 'settings' as ViewName },
{ icon: Users, label: t('admin.manage_students'), desc: t('admin.add_student'), color: 'from-emerald-400 to-teal-500', view: 'classes' as ViewName },
{ icon: Heart, label: t('admin.manage_parents'), desc: t('admin.manage_parents'), color: 'from-rose-400 to-pink-500', view: 'parents' as ViewName },
{ icon: School, label: t('admin.school_settings'), desc: t('admin.school_settings'), color: 'from-violet-400 to-purple-500', view: 'settings' as ViewName },
].map((action) => (
<motion.button
key={action.label}
onClick={() => setCurrentView(action.view)}
whileHover={{ scale: 1.01, x: 2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.15 }}
className="w-full group flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-teal-200/60 dark:hover:border-teal-800/30 transition-all min-h-[44px]"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${action.color} text-white shadow-sm shrink-0 transition-transform duration-200 group-hover:scale-110`}>
<action.icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{action.label}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400 truncate">{action.desc}</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-teal-500 transition-colors shrink-0" />
</motion.button>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
onClick={() => setCurrentView('classes')}
className="bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-600 hover:to-emerald-600 text-white min-h-[44px] px-4 font-semibold shadow-sm"
>
<Plus className="h-4 w-4 mr-1.5" />
{t('admin.add_class')}
</Button>
<Button
variant="outline"
onClick={() => setCurrentView('settings')}
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 min-h-[44px] px-4"
>
<Settings className="h-4 w-4 mr-1.5" />
{t('admin.school_settings')}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Announcements Management */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
{/* School Newsletter */}
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
@@ -1024,6 +1549,75 @@ export default function DashboardView() {
</CardContent>
</Card>
</motion.div>
{/* Student Grades Trend Chart */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm">
<TrendingUp className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.my_grades_trend')}</CardTitle>
</div>
<div className="flex items-center gap-1">
<ArrowUpRight className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{t('student.grade_trend_up')}</span>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-1">
<ResponsiveContainer width="100%" height={160}>
<BarChart data={[
{ subject: t('student.notebook_math').substring(0, 3), grade: 2.3 },
{ subject: t('student.notebook_german').substring(0, 3), grade: 1.8 },
{ subject: t('student.notebook_english').substring(0, 3), grade: 2.7 },
{ subject: t('student.notebook_science').substring(0, 3), grade: 2.1 },
]}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="subject" tick={{ fontSize: 10 }} stroke="#9ca3af" />
<YAxis domain={[1, 6]} reversed tick={{ fontSize: 10 }} stroke="#9ca3af" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Bar dataKey="grade" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</motion.div>
{/* Student Competition Standings */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]" onClick={() => setCurrentView('competitions')}>
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
<Trophy className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold">{t('student.competition_standings')}</CardTitle>
</div>
<ChevronRight className="h-4 w-4 text-amber-500" />
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="flex items-center justify-center h-20 rounded-lg bg-gradient-to-br from-amber-50 to-yellow-50/50 dark:from-amber-900/20 dark:to-yellow-900/10 border border-amber-200/30 dark:border-amber-900/20">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-yellow-500 text-white shadow-sm">
<Trophy className="h-5 w-5" />
</div>
<div className="text-center">
<p className="text-lg font-bold text-amber-600 dark:text-amber-400">#12</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">{t('student.my_rank')} · 245 {t('student.points')}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Student School Announcements */}
<DashboardAnnouncementsCard currentUser={currentUser} locale={locale} />
</motion.div>
);
}
@@ -1039,6 +1633,18 @@ export default function DashboardView() {
);
}
// ─── School Admin Dashboard ─────────────────────────────────────────
if (currentUser?.role === 'SCHOOL_ADMIN') {
return (
<SchoolAdminDashboard
currentUser={currentUser}
setCurrentView={setCurrentView}
locale={locale}
data={data}
/>
);
}
return (
<motion.div
variants={containerVariants}
@@ -1209,7 +1815,7 @@ export default function DashboardView() {
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Card className={`card-hover-lift border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
<Card className={`card-shadow-transition border-0 shadow-sm rounded-xl border-l-3 ${colors.borderAccent} overflow-hidden transition-all duration-300 ${colors.hoverShadow} ${colors.glowColor} cursor-default ring-1 ring-black/[0.03] dark:ring-white/[0.05] hover:ring-emerald-200/40 dark:hover:ring-emerald-700/30`}>
<CardContent className={`p-5 ${colors.bg}`}>
<div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 leading-tight line-clamp-2 min-h-[28px] break-words min-w-0">
@@ -1426,6 +2032,94 @@ export default function DashboardView() {
</Card>
</motion.div>
{/* ===== TEACHER COMPETENCE PROGRESS RADAR ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm">
<Flower2 className="h-4 w-4" />
</div>
{t('teacher.competence_progress')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">{t('teacher.competence_progress_desc')}</span>
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<RadarChart data={[
{ subject: t('student.notebook_math'), current: 3.5, target: 4 },
{ subject: t('student.notebook_german'), current: 3.8, target: 4 },
{ subject: t('student.notebook_english'), current: 2.9, target: 4 },
{ subject: t('student.notebook_science'), current: 3.2, target: 4 },
{ subject: locale === 'de' ? 'Musik' : 'Music', current: 3.6, target: 4 },
{ subject: locale === 'de' ? 'Sport' : 'PE', current: 4.0, target: 4 },
]}>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11, fill: '#6b7280' }} />
<PolarRadiusAxis angle={30} domain={[0, 4]} tick={{ fontSize: 10 }} />
<Radar name={locale === 'de' ? 'Aktuell' : 'Current'} dataKey="current" stroke="#10b981" fill="#10b981" fillOpacity={0.3} />
<Radar name={locale === 'de' ? 'Ziel' : 'Target'} dataKey="target" stroke="#14b8a6" fill="#14b8a6" fillOpacity={0.1} strokeDasharray="5 5" />
<RechartsTooltip contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px' }} />
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</motion.div>
{/* ===== UPCOMING LESSONS ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-5 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<Clock className="h-4 w-4" />
</div>
{t('teacher.upcoming_lessons')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1">
· {new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{data.classesOverview.length === 0 ? (
<div className="text-center py-6">
<Clock className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('teacher.no_lessons')}</p>
</div>
) : (
<div className="space-y-2">
{data.classesOverview.slice(0, 4).map((cls, idx) => {
const times = ['08:00 – 08:45', '09:00 – 09:45', '10:00 – 10:45', '11:00 – 11:45'];
const subjects = [t('student.notebook_math'), t('student.notebook_german'), t('student.notebook_english'), t('student.notebook_science')];
const colors = ['from-amber-400 to-orange-500', 'from-emerald-400 to-teal-500', 'from-rose-400 to-pink-500', 'from-violet-400 to-purple-500'];
return (
<motion.div
key={cls.id}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="group flex items-center gap-3 p-3 rounded-xl bg-gray-50/50 dark:bg-gray-800/30 border border-gray-100/60 dark:border-gray-800/40 hover:border-violet-200/60 dark:hover:border-violet-800/30 transition-colors min-h-[44px]"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br ${colors[idx % colors.length]} text-white shadow-sm shrink-0`}>
<BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{subjects[idx % subjects.length]} — {cls.name}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">{times[idx % times.length]} · {t('teacher.student_count')}: {cls.studentCount}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{times[idx % times.length].split(' – ')[0]}</Badge>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600 group-hover:text-violet-500 transition-colors" />
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* ===== TODAY'S SCHEDULE ===== */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05] card-hover-lift">
@@ -1808,7 +2502,7 @@ export default function DashboardView() {
<p className="text-xs text-emerald-600/60 dark:text-emerald-400/40 mt-1">{t('dashboard.start_here')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto scrollbar-education pr-1">
<div className="activity-timeline max-h-96 overflow-y-auto scrollbar-education pr-1">
{data.recentEntries.slice(0, typeof window !== 'undefined' && window.innerWidth < 640 ? 5 : 10).map((entry) => {
const catColor = entry.competency.category.color ?? '#10b981';
const studentName = `${entry.student.firstName} ${entry.student.lastName}`;
@@ -1818,7 +2512,7 @@ export default function DashboardView() {
key={entry.id}
whileHover={{ scale: 1.01, x: 2 }}
transition={{ duration: 0.15 }}
className="group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden"
className="activity-timeline-item group flex items-center justify-between p-3.5 rounded-xl bg-gradient-to-r from-gray-50/80 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-100/60 dark:border-gray-800/40 hover:border-emerald-200/60 dark:hover:border-emerald-800/30 transition-colors relative overflow-hidden"
>
{/* Left border color indicator */}
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl transition-all duration-200 group-hover:w-1.5" style={{ backgroundColor: catColor }} />
+50 -6
View File
@@ -26,6 +26,7 @@ import {
ZoomIn,
ZoomOut,
Activity,
Highlighter,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
@@ -73,7 +74,7 @@ export interface StrokePoint {
export interface Stroke {
id: string;
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser';
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
color: string;
width: number;
points: StrokePoint[];
@@ -93,7 +94,7 @@ export interface DrawingCanvasProps {
title?: string;
}
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser';
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter';
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted';
type GuideMode = 'off' | 'basic' | 'circles' | 'perspective';
@@ -111,6 +112,11 @@ const PEN_COLORS = [
'#533483', '#2b2d42', '#8d99ae', '#ef233c',
];
const HIGHLIGHTER_COLORS = [
'#fef08a', '#bbf7d0', '#bfdbfe', '#fbcfe8',
'#fed7aa', '#c4b5fd', '#99f6e4', '#fca5a5',
];
const AUTO_SAVE_INTERVAL = 30000;
const AUTO_SAVE_DEBOUNCE = 5000; // Debounced auto-save: 5s after last stroke
const LOCAL_STORAGE_KEY = 'ct_drawing_draft';
@@ -355,6 +361,11 @@ function drawStroke(
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)';
ctx.lineWidth = stroke.width * 2;
} else if (stroke.tool === 'highlighter') {
ctx.globalCompositeOperation = 'multiply';
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width * 4;
ctx.globalAlpha = 0.35;
} else {
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
@@ -364,7 +375,7 @@ function drawStroke(
ctx.globalAlpha = 0.7;
}
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser') {
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser' || stroke.tool === 'highlighter') {
if (stroke.points.length === 1) {
const p = stroke.points[0];
const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width;
@@ -487,6 +498,16 @@ export default function DrawingCanvas({
const [bgType, setBgType] = useState<BackgroundType>(backgroundType);
const [guideMode, setGuideMode] = useState<GuideMode>('off');
// Set default color for highlighter
const handleToolChange = useCallback((tool: ToolType) => {
setActiveTool(tool);
if (tool === 'highlighter' && strokeColor === '#000000') {
setStrokeColor('#fef08a');
} else if (tool !== 'highlighter' && strokeColor === '#fef08a') {
setStrokeColor('#000000');
}
}, [strokeColor]);
// UI state
const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
@@ -885,6 +906,7 @@ export default function DrawingCanvas({
const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [
{ id: 'pencil', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_pencil') },
{ id: 'pen', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_pen') },
{ id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') },
{ id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') },
{ id: 'rectangle', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_rectangle') },
{ id: 'circle', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_circle') },
@@ -943,7 +965,7 @@ export default function DrawingCanvas({
<Button
variant="ghost"
size="sm"
onClick={() => setActiveTool(tool.id)}
onClick={() => handleToolChange(tool.id)}
className={`h-10 w-10 min-touch transition-all rounded-lg ${
activeTool === tool.id
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105'
@@ -997,9 +1019,9 @@ export default function DrawingCanvas({
/>
))}
</div>
<p className="mt-2 text-xs font-medium text-gray-500">
<div className="mt-2 text-xs font-medium text-gray-500">
{t('drawing.tool_pen')} {t('drawing.color').toLowerCase()}
</p>
</div>
<div className="grid grid-cols-8 gap-1.5">
{PEN_COLORS.map((color) => (
<button
@@ -1015,6 +1037,28 @@ export default function DrawingCanvas({
/>
))}
</div>
{activeTool === 'highlighter' && (
<>
<p className="mt-2 text-xs font-medium text-gray-500">
{t('drawing.tool_highlighter')}
</p>
<div className="grid grid-cols-8 gap-1.5">
{HIGHLIGHTER_COLORS.map((color) => (
<button
key={color}
onClick={() => setStrokeColor(color)}
className={`h-8 w-8 min-touch rounded-full border-2 transition-all hover:scale-110 ${
strokeColor === color
? 'border-emerald-500 ring-2 ring-emerald-500/30 scale-110'
: 'border-gray-300'
}`}
style={{ backgroundColor: color }}
aria-label={`Highlighter color ${color}`}
/>
))}
</div>
</>
)}
<div className="mt-2 flex items-center gap-2">
<Label className="text-xs text-gray-500">{t('drawing.color')}</Label>
<input
+647 -61
View File
@@ -17,6 +17,9 @@ import {
Clock, History, ZoomIn, ZoomOut, Image as ImageIcon,
Users as UsersIcon, Radio, MousePointer2, Columns2,
ChevronRight, XCircle, Trophy, Zap, Flame, Dumbbell, Heart, RotateCcw, ArrowRight, Play,
StickyNote, LayoutTemplate, Network, GitCompare, CalendarDays,
FolderOpen, Search as SearchIcon, ClipboardCopy, Tag,
Minus, CircleDot, Sparkles as SparklesIcon,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -104,6 +107,101 @@ interface PageVersion {
createdAt: string;
}
// ─── Sticky Note Type ────────────────────────────────────────────────
interface StickyNoteData {
id: string;
x: number;
y: number;
width: number;
height: number;
color: string;
text: string;
pageId: string;
}
// ─── Section Type ────────────────────────────────────────────────────
interface SectionData {
id: string;
name: string;
color: string;
pageIds: string[];
}
// ─── Page Template Definitions ───────────────────────────────────────
const PAGE_TEMPLATES: Array<{
key: string;
titleKey: string;
descKey: string;
icon: React.ElementType;
background: string;
getContent: () => string;
}> = [
{
key: 'blank',
titleKey: 'notebooks.page_template_blank',
descKey: 'notebooks.type_blank_desc',
icon: File,
background: 'blank',
getContent: () => '',
},
{
key: 'cornell',
titleKey: 'notebooks.page_template_cornell',
descKey: 'notebooks.page_template_cornell_desc',
icon: LayoutTemplate,
background: 'lined',
getContent: () => `<div style="display:grid;grid-template-columns:1fr 2fr;grid-template-rows:auto 1fr;gap:8px;min-height:500px;border:1px solid #e5e7eb;border-radius:8px;padding:8px;"><div style="border-right:1px solid #e5e7eb;padding-right:8px;font-size:12px;color:#9ca3af;"><b>Stichworte</b><br><br><br></div><div style="padding-left:8px;"><b>Notizen</b><br><br><br></div><div style="grid-column:1/-1;border-top:1px solid #e5e7eb;padding-top:8px;"><b>Zusammenfassung</b><br><br></div></div>`,
},
{
key: 'mindmap',
titleKey: 'notebooks.page_template_mindmap',
descKey: 'notebooks.page_template_mindmap_desc',
icon: Network,
background: 'blank',
getContent: () => `<div style="display:flex;align-items:center;justify-content:center;min-height:500px;"><div style="position:relative;width:100%;height:100%;"><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:120px;height:120px;border-radius:50%;border:3px solid #10b981;display:flex;align-items:center;justify-content:center;font-weight:bold;color:#10b981;">Zentrales Thema</div><div style="position:absolute;top:20%;left:15%;width:80px;height:80px;border-radius:50%;border:2px solid #3b82f6;display:flex;align-items:center;justify-content:center;font-size:12px;color:#3b82f6;">Idee 1</div><div style="position:absolute;top:20%;right:15%;width:80px;height:80px;border-radius:50%;border:2px solid #ef4444;display:flex;align-items:center;justify-content:center;font-size:12px;color:#ef4444;">Idee 2</div><div style="position:absolute;bottom:20%;left:15%;width:80px;height:80px;border-radius:50%;border:2px solid #f59e0b;display:flex;align-items:center;justify-content:center;font-size:12px;color:#f59e0b;">Idee 3</div><div style="position:absolute;bottom:20%;right:15%;width:80px;height:80px;border-radius:50%;border:2px solid #8b5cf6;display:flex;align-items:center;justify-content:center;font-size:12px;color:#8b5cf6;">Idee 4</div></div></div>`,
},
{
key: 'venn',
titleKey: 'notebooks.page_template_venn',
descKey: 'notebooks.page_template_venn_desc',
icon: GitCompare,
background: 'blank',
getContent: () => `<div style="display:flex;align-items:center;justify-content:center;min-height:500px;position:relative;"><div style="position:absolute;width:260px;height:260px;border-radius:50%;border:3px solid #3b82f6;background:rgba(59,130,246,0.08);display:flex;align-items:center;justify-content:center;left:calc(50% - 180px);"><span style="font-size:12px;color:#3b82f6;font-weight:bold;">Menge A</span></div><div style="position:absolute;width:260px;height:260px;border-radius:50%;border:3px solid #ef4444;background:rgba(239,68,68,0.08);display:flex;align-items:center;justify-content:center;left:calc(50% - 80px);"><span style="font-size:12px;color:#ef4444;font-weight:bold;">Menge B</span></div><div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);font-size:11px;color:#6b7280;font-weight:bold;">Gemeinsam</div></div>`,
},
{
key: 'tchart',
titleKey: 'notebooks.page_template_tchart',
descKey: 'notebooks.page_template_tchart_desc',
icon: Columns2,
background: 'lined',
getContent: () => `<div style="display:grid;grid-template-columns:1fr 1fr;min-height:500px;border:1px solid #e5e7eb;border-radius:8px;"><div style="border-right:2px solid #10b981;padding:12px;"><b>Seite A</b><br><br><br></div><div style="padding:12px;"><b>Seite B</b><br><br><br></div></div>`,
},
{
key: 'weekly',
titleKey: 'notebooks.page_template_weekly',
descKey: 'notebooks.page_template_weekly_desc',
icon: CalendarDays,
background: 'grid',
getContent: () => `<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px;min-height:500px;"><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#f0fdf4;"><b>Montag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#eff6ff;"><b>Dienstag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#fef3c7;"><b>Mittwoch</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#fce7f3;"><b>Donnerstag</b><br><br></div><div style="border:1px solid #e5e7eb;border-radius:4px;padding:8px;background:#f3e8ff;"><b>Freitag</b><br><br></div></div>`,
},
];
const STICKY_NOTE_COLORS = [
{ key: 'yellow', color: '#fef08a', border: '#fbbf24' },
{ key: 'green', color: '#bbf7d0', border: '#34d399' },
{ key: 'blue', color: '#bfdbfe', border: '#60a5fa' },
{ key: 'pink', color: '#fbcfe8', border: '#f472b6' },
{ key: 'orange', color: '#fed7aa', border: '#fb923c' },
{ key: 'purple', color: '#ddd6fe', border: '#a78bfa' },
];
const SECTION_COLORS = [
'#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
];
// ─── Constants ───────────────────────────────────────────────────────
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [
@@ -416,7 +514,7 @@ function WysiwygToolbar({ editorRef, onFormatChange }: { editorRef: React.RefObj
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
<div className="glass-toolbar flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
{/* Format group */}
<Tooltip>
<TooltipTrigger asChild>
@@ -635,7 +733,7 @@ function NotebookCard({
>
{/* Cover section */}
<div
className="relative h-28 flex items-center justify-center overflow-hidden"
className="notebook-cover relative h-28 flex items-center justify-center overflow-hidden"
style={{
background: `linear-gradient(135deg, ${notebook.color}, ${notebook.color}cc)`,
}}
@@ -1051,16 +1149,18 @@ function NotebookDetailView({
onToggleBookmark,
onTogglePublic,
onReorderPages,
onDuplicatePage,
}: {
notebook: Notebook;
subjectName: string | null;
onBack: () => void;
onUpdatePage: (pageId: string, data: Partial<NotebookPage>) => Promise<void>;
onAddPage: () => void;
onAddPage: (templateKey?: string, background?: string, content?: string) => Promise<void>;
onDeletePage: (pageId: string) => void;
onToggleBookmark: (pageId: string) => void;
onTogglePublic: () => void;
onReorderPages: (pageOrders: Array<{ id: string; pageNumber: number }>) => Promise<void>;
onDuplicatePage: (pageId: string) => Promise<void>;
}) {
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [pageContent, setPageContent] = useState('');
@@ -1084,6 +1184,27 @@ function NotebookDetailView({
// Collaboration state
const [collabActivityOpen, setCollabActivityOpen] = useState(false);
// Sticky notes state
const [stickyNotes, setStickyNotes] = useState<StickyNoteData[]>([]);
const [showStickyNoteDialog, setShowStickyNoteDialog] = useState(false);
const [newStickyNoteColor, setNewStickyNoteColor] = useState('#fef08a');
const [editingStickyNote, setEditingStickyNote] = useState<StickyNoteData | null>(null);
// Search within notebook state
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Array<{ pageId: string; pageTitle: string; snippet: string; pageNumber: number }>>([]);
// Page template chooser state
const [templateChooserOpen, setTemplateChooserOpen] = useState(false);
// Sections state
const [sections, setSections] = useState<SectionData[]>([]);
const [showSectionDialog, setShowSectionDialog] = useState(false);
const [newSectionName, setNewSectionName] = useState('');
const [newSectionColor, setNewSectionColor] = useState('#10b981');
const [showTableOfContents, setShowTableOfContents] = useState(false);
const editorRef = useRef<HTMLDivElement>(null);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedContentRef = useRef<string>('');
@@ -1450,6 +1571,94 @@ function NotebookDetailView({
setDragOverPageId(null);
}, [dragOverPageId, handleDrop]);
// ─── Sticky Note Handlers ──────────────────────────────────────────
const addStickyNote = useCallback(() => {
if (!currentPage) return;
const note: StickyNoteData = {
id: `sticky_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
x: 10 + Math.random() * 30,
y: 10 + Math.random() * 30,
width: 180,
height: 120,
color: newStickyNoteColor,
text: '',
pageId: currentPage.id,
};
setStickyNotes(prev => [...prev, note]);
setShowStickyNoteDialog(false);
}, [currentPage, newStickyNoteColor]);
const updateStickyNote = useCallback((id: string, updates: Partial<StickyNoteData>) => {
setStickyNotes(prev => prev.map(n => n.id === id ? { ...n, ...updates } : n));
}, []);
const deleteStickyNote = useCallback((id: string) => {
setStickyNotes(prev => prev.filter(n => n.id !== id));
}, []);
const currentPageStickyNotes = useMemo(
() => stickyNotes.filter(n => n.pageId === currentPageId),
[stickyNotes, currentPageId]
);
// ─── Search Within Notebook ────────────────────────────────────────
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults([]);
return;
}
const q = query.toLowerCase();
const results: Array<{ pageId: string; pageTitle: string; snippet: string; pageNumber: number }> = [];
for (const page of pages) {
const plainText = (page.textContent ?? '').replace(/<[^>]*>/g, '');
const title = page.title ?? `${t('notebooks.page')} ${page.pageNumber}`;
const combined = (title + ' ' + plainText).toLowerCase();
if (combined.includes(q)) {
const idx = combined.indexOf(q);
const start = Math.max(0, idx - 30);
const end = Math.min(plainText.length, idx + query.length + 30);
const snippet = (start > 0 ? '...' : '') + plainText.substring(start, end) + (end < plainText.length ? '...' : '');
results.push({ pageId: page.id, pageTitle: title, snippet, pageNumber: page.pageNumber });
}
}
setSearchResults(results);
}, [pages]);
// ─── Section Handlers ──────────────────────────────────────────────
const addSection = useCallback(() => {
if (!newSectionName.trim()) return;
const section: SectionData = {
id: `section_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
name: newSectionName.trim(),
color: newSectionColor,
pageIds: [],
};
setSections(prev => [...prev, section]);
setNewSectionName('');
setShowSectionDialog(false);
}, [newSectionName, newSectionColor]);
const deleteSection = useCallback((sectionId: string) => {
setSections(prev => prev.filter(s => s.id !== sectionId));
}, []);
const getSectionForPage = useCallback((pageId: string): SectionData | null => {
return sections.find(s => s.pageIds.includes(pageId)) ?? null;
}, [sections]);
// ─── Page Template Handler ─────────────────────────────────────────
const handleAddFromTemplate = useCallback(async (templateKey: string) => {
const template = PAGE_TEMPLATES.find(t => t.key === templateKey);
if (!template) return;
setTemplateChooserOpen(false);
await onAddPage(templateKey, template.background, template.getContent());
}, [onAddPage]);
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
return (
@@ -1626,6 +1835,219 @@ function NotebookDetailView({
<Clock className="w-4 h-4" />
</Button>
{/* Search within notebook */}
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<SearchIcon className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.search_pages')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-80 p-0 rounded-xl">
<div className="p-3">
<Input
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t('notebooks.search_pages_placeholder')}
className="min-h-[44px]"
autoFocus
/>
</div>
{searchQuery.trim() && (
<div className="border-t border-gray-100 dark:border-gray-800">
<ScrollArea className="max-h-60">
{searchResults.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{t('notebooks.search_no_results')}
</div>
) : (
<div className="p-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{searchResults.length} {t('notebooks.search_results')}
</div>
{searchResults.map((result) => (
<button
key={result.pageId}
onClick={() => { setCurrentPageId(result.pageId); setSearchOpen(false); }}
className="w-full text-left p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors min-h-[44px]"
>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{result.pageTitle}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{result.snippet}
</div>
</button>
))}
</div>
)}
</ScrollArea>
</div>
)}
</PopoverContent>
</Popover>
{/* Sticky note button */}
<Popover open={showStickyNoteDialog} onOpenChange={setShowStickyNoteDialog}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<StickyNote className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.sticky_note_add')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-auto p-3 rounded-xl">
<p className="text-sm font-medium mb-2">{t('notebooks.sticky_note_color')}</p>
<div className="flex items-center gap-2 mb-3">
{STICKY_NOTE_COLORS.map((sc) => (
<button
key={sc.key}
onClick={() => setNewStickyNoteColor(sc.color)}
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 ${
newStickyNoteColor === sc.color ? 'border-gray-900 dark:border-white scale-110 shadow-md' : 'border-transparent'
}`}
style={{ backgroundColor: sc.color }}
/>
))}
</div>
<Button onClick={addStickyNote} className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700" size="sm">
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.sticky_note_add')}
</Button>
</PopoverContent>
</Popover>
{/* Sections button */}
<Popover open={showSectionDialog} onOpenChange={setShowSectionDialog}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<FolderOpen className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.sections')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-sm font-semibold">{t('notebooks.sections')}</h3>
</div>
<div className="p-3 space-y-3">
<Input
value={newSectionName}
onChange={(e) => setNewSectionName(e.target.value)}
placeholder={t('notebooks.section_name_placeholder')}
className="min-h-[44px]"
/>
<div className="flex items-center gap-2 flex-wrap">
{SECTION_COLORS.map((color) => (
<button
key={color}
onClick={() => setNewSectionColor(color)}
className={`w-6 h-6 rounded-full border-2 transition-all hover:scale-110 ${
newSectionColor === color ? 'border-gray-900 dark:border-white scale-110' : 'border-transparent'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<Button
onClick={addSection}
disabled={!newSectionName.trim()}
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.section_add')}
</Button>
</div>
{sections.length > 0 && (
<div className="border-t border-gray-100 dark:border-gray-800 p-2 space-y-1">
{sections.map((section) => (
<div key={section.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: section.color }} />
<span className="flex-1 truncate text-gray-700 dark:text-gray-300">{section.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSection(section.id)}
className="h-7 w-7 p-0 text-gray-400 hover:text-red-500"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
{/* Table of Contents button */}
<Popover open={showTableOfContents} onOpenChange={setShowTableOfContents}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px] shrink-0">
<BookMarked className="w-4 h-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">{t('notebooks.table_of_contents')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent align="end" className="w-72 p-0 rounded-xl">
<div className="p-3 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-sm font-semibold">{t('notebooks.table_of_contents')}</h3>
</div>
<ScrollArea className="max-h-60">
{pages.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{t('notebooks.no_sections')}
</div>
) : (
<div className="p-2 space-y-0.5">
{pages.map((page) => {
const section = getSectionForPage(page.id);
return (
<div key={page.id}>
{section && page.id === section.pageIds[0] && (
<div className="flex items-center gap-2 px-2 py-1 mt-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: section.color }} />
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">{section.name}</span>
</div>
)}
<button
onClick={() => { setCurrentPageId(page.id); setShowTableOfContents(false); }}
className="w-full text-left px-3 py-1.5 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center gap-2 min-h-[36px]"
>
<span className="text-xs text-gray-400 font-mono w-5 shrink-0">{page.pageNumber}</span>
<span className="truncate text-gray-700 dark:text-gray-300">{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}</span>
{page.isBookmark && <Bookmark className="w-3 h-3 text-amber-500 fill-amber-500 shrink-0" />}
</button>
</div>
);
})}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* Public toggle */}
<Button
variant="ghost"
@@ -1641,7 +2063,7 @@ function NotebookDetailView({
<div className="flex flex-1 overflow-hidden">
{/* Left sidebar: page navigation */}
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col">
<div className="p-3">
<div className="p-3 space-y-2">
<Button
onClick={onAddPage}
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
@@ -1650,6 +2072,15 @@ function NotebookDetailView({
<Plus className="w-4 h-4 mr-1" />
{t('notebooks.add_page')}
</Button>
<Button
onClick={() => setTemplateChooserOpen(true)}
variant="outline"
className="w-full min-h-[44px]"
size="sm"
>
<LayoutTemplate className="w-4 h-4 mr-1" />
{t('notebooks.page_template')}
</Button>
</div>
<ScrollArea className="flex-1 max-h-[calc(100vh-280px)]">
@@ -1657,65 +2088,101 @@ function NotebookDetailView({
{pages.map((page, idx) => {
const isDragged = draggedPageId === page.id;
const isDragOver = dragOverPageId === page.id;
const section = getSectionForPage(page.id);
const isFirstInSection = section && section.pageIds[0] === page.id;
return (
<motion.div
key={page.id}
initial={{ opacity: 0, x: -10 }}
animate={{
opacity: isDragged ? 0.5 : 1,
x: 0,
scale: isDragged ? 0.95 : 1,
}}
transition={{ delay: idx * 0.03 }}
layout
className={`rounded-lg transition-all ${
currentPageId === page.id
? 'ring-2 ring-emerald-500'
: ''
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
isDragged ? 'shadow-md' : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(page.id)}
onDragEnd={handleDragEnd}
onTouchStart={(e) => handleTouchStart(e, page.id)}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Drag handle */}
<div className="flex items-center">
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
<div className="flex-1">
{/* Page thumbnail */}
<PageThumbnail page={page} notebookType={notebook.notebookType} />
</div>
</div>
{/* Page info below thumbnail */}
<button
onClick={() => { setCurrentPageId(page.id); }}
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
<React.Fragment key={page.id}>
{/* Section divider */}
{section && isFirstInSection && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700"
>
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: section.color }} />
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400 truncate flex-1">{section.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => deleteSection(section.id)}
className="h-6 w-6 p-0 text-gray-400 hover:text-red-500 shrink-0"
>
<X className="w-3 h-3" />
</Button>
</motion.div>
)}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{
opacity: isDragged ? 0.5 : 1,
x: 0,
scale: isDragged ? 0.95 : 1,
}}
transition={{ delay: idx * 0.03 }}
layout
className={`rounded-lg transition-all ${
currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
? 'ring-2 ring-emerald-500'
: ''
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
isDragged ? 'shadow-md' : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(page.id)}
onDragEnd={handleDragEnd}
onTouchStart={(e) => handleTouchStart(e, page.id)}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
{page.pageNumber}
</span>
<span className="truncate flex-1">
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
</span>
{page.isBookmark && (
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
)}
</button>
</motion.div>
{/* Drag handle */}
<div className="flex items-center">
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
<div className="flex-1">
{/* Page thumbnail */}
<PageThumbnail page={page} notebookType={notebook.notebookType} />
</div>
</div>
{/* Page info below thumbnail */}
<button
onClick={() => { setCurrentPageId(page.id); }}
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
currentPageId === page.id
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
{page.pageNumber}
</span>
<span className="truncate flex-1">
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
</span>
{page.isBookmark && (
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
)}
{/* Duplicate page button */}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); onDuplicatePage(page.id); }}
className="h-6 w-6 flex items-center justify-center rounded text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 shrink-0 transition-colors"
>
<ClipboardCopy className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">{t('notebooks.duplicate_page')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
</motion.div>
</React.Fragment>
);
})}
</div>
@@ -1958,6 +2425,47 @@ function NotebookDetailView({
</div>
</motion.div>
))}
{/* Sticky Notes Overlay */}
{currentPageStickyNotes.map((note) => (
<motion.div
key={note.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute z-30 shadow-lg rounded-md overflow-hidden group"
style={{
left: `${note.x}%`,
top: `${note.y}%`,
width: `${note.width}px`,
minHeight: `${note.height}px`,
backgroundColor: note.color,
}}
>
<div className="flex items-center justify-between px-2 py-1 bg-black/5">
<span className="text-xs font-medium text-gray-600">{t('notebooks.sticky_note')}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setEditingStickyNote(note)}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-black/10 text-gray-600"
>
<Edit3 className="w-3 h-3" />
</button>
<button
onClick={() => deleteStickyNote(note.id)}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-black/10 text-gray-600"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<Textarea
value={note.text}
onChange={(e) => updateStickyNote(note.id, { text: e.target.value })}
className="border-0 bg-transparent shadow-none resize-none text-sm min-h-[80px] p-2 focus-visible:ring-0"
placeholder={t('notebooks.sticky_note_edit') + '...'}
/>
</motion.div>
))}
</div>
</div>
)}
@@ -2019,6 +2527,40 @@ function NotebookDetailView({
</div>
</div>
{/* Page Template Chooser Dialog */}
<Dialog open={templateChooserOpen} onOpenChange={setTemplateChooserOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LayoutTemplate className="w-5 h-5 text-emerald-500" />
{t('notebooks.page_template_choose')}
</DialogTitle>
<DialogDescription>{t('notebooks.page_template')}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
{PAGE_TEMPLATES.map((template) => {
const TemplateIcon = template.icon;
return (
<button
key={template.key}
onClick={() => handleAddFromTemplate(template.key)}
className="flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all min-h-[100px] hover:border-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
>
<TemplateIcon className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t(template.titleKey)}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 text-center">{t(template.descKey)}</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTemplateChooserOpen(false)} className="min-h-[44px]">
{t('action.cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Version History Dialog */}
<VersionHistoryDialog
open={versionHistoryOpen}
@@ -3315,12 +3857,14 @@ export default function NotebooksView() {
});
}, [selectedNotebook]);
const handleAddPage = useCallback(async () => {
const handleAddPage = useCallback(async (templateKey?: string, background?: string, content?: string) => {
if (!selectedNotebook) return;
const pagesCount = selectedNotebook.pages?.length ?? 0;
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
pageNumber: pagesCount + 1,
background: selectedNotebook.notebookType,
background: background ?? selectedNotebook.notebookType,
title: templateKey && templateKey !== 'blank' ? t(`notebooks.page_template_${templateKey}`) : null,
textContent: content ?? null,
});
setSelectedNotebook(prev => {
if (!prev) return prev;
@@ -3332,6 +3876,32 @@ export default function NotebooksView() {
toast.success(t('notebooks.page_added'));
}, [selectedNotebook]);
const handleDuplicatePage = useCallback(async (pageId: string) => {
if (!selectedNotebook) return;
const page = selectedNotebook.pages?.find(p => p.id === pageId);
if (!page) return;
const pagesCount = selectedNotebook.pages?.length ?? 0;
try {
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
pageNumber: pagesCount + 1,
background: page.background,
title: (page.title ?? '') + ' ' + t('notebooks.copy_suffix'),
textContent: page.textContent,
drawingData: page.drawingData,
});
setSelectedNotebook(prev => {
if (!prev) return prev;
return {
...prev,
pages: [...(prev.pages ?? []), newPage],
};
});
toast.success(t('notebooks.duplicate_page_success'));
} catch {
toast.error(t('notebooks.duplicate_page_error'));
}
}, [selectedNotebook]);
const handleDeletePage = useCallback(async (pageId: string) => {
if (!selectedNotebook) return;
await apiDelete(`/api/notebooks/${selectedNotebook.id}/pages/${pageId}`);
@@ -3525,6 +4095,7 @@ export default function NotebooksView() {
onToggleBookmark={handleToggleBookmark}
onTogglePublic={handleTogglePublic}
onReorderPages={handleReorderPages}
onDuplicatePage={handleDuplicatePage}
/>
</div>
);
@@ -4013,6 +4584,21 @@ export default function NotebooksView() {
)}
</div>
{/* Floating Action Button for new notebook */}
{!selectedNotebook && !showArchived && (
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="fab"
onClick={() => setCreateOpen(true)}
title={t('notebooks.create')}
>
<Plus className="w-6 h-6" />
</motion.button>
)}
{/* Eco footer bar */}
<div className="bg-emerald-50 dark:bg-emerald-900/20 border-t border-emerald-200 dark:border-emerald-800 px-4 sm:px-6 py-3">
<div className="flex items-center gap-3 text-emerald-700 dark:text-emerald-300 text-sm">
+3 -5
View File
@@ -69,10 +69,8 @@ function getMasteryIcon(level: number) {
}
const masteryBadge = (level: number) => {
// Use CSS class-based mastery badges for deeper polish
if (level <= 2) return 'mastery-badge-low';
if (level <= 4) return 'mastery-badge-medium';
return 'mastery-badge-high';
// Use CSS class-based mastery indicators for deeper polish
return `mastery-indicator mastery-indicator-${level}`;
};
const masteryBorderColor = (level: number) => {
@@ -899,7 +897,7 @@ export default function ProgressEntriesView() {
<motion.div
whileHover={{ scale: 1.005 }}
transition={{ duration: 0.15 }}
className={`card-hover-lift flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
className={`card-shadow-transition flex-1 p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border-l-3 transition-shadow duration-200 hover:shadow-lg ${
isSelected && batchMode ? 'ring-2 ring-red-300/60 dark:ring-red-700/50 bg-red-50/30 dark:bg-red-900/10' : ''
}`}
style={{ borderLeftColor: catColor }}
+129
View File
@@ -1130,6 +1130,135 @@ function PrivacyTab({ currentUser }: { currentUser: { id: string; role: string;
</Card>
)}
{/* ── Consent Management Card ─────────────────────────────── */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<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">
<Shield className="h-4 w-4" />
</div>
{t('dsgvo.consent_management')}
</CardTitle>
<CardDescription>{t('dsgvo.consent_management_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'data_processing', icon: Database, color: 'emerald' },
{ key: 'communication', icon: Mail, color: 'teal' },
{ key: 'analytics', icon: BarChart3, color: 'amber' },
{ key: 'third_party', icon: Users, color: 'violet' },
].map((consent) => {
const Icon = consent.icon;
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/30 dark:border-emerald-900/20' },
teal: { bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/30 dark:border-teal-900/20' },
amber: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200/30 dark:border-amber-900/20' },
violet: { bg: 'bg-violet-100 dark:bg-violet-900/30', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/30 dark:border-violet-900/20' },
};
const c = colorMap[consent.color] || colorMap.emerald;
return (
<div key={consent.key} className={`flex items-center justify-between p-4 rounded-xl ${c.bg} border ${c.border}`}>
<div className="flex items-center gap-3">
<Icon className={`h-5 w-5 ${c.text}`} />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t(`dsgvo.consent_${consent.key}`)}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t(`dsgvo.consent_${consent.key}_desc`)}</p>
</div>
</div>
<Badge className={`${c.bg} ${c.text} border ${c.border} text-xs`}>
{t('dsgvo.consent_granted')}
</Badge>
</div>
);
})}
</CardContent>
</Card>
{/* ── Data Retention Settings Card ─────────────────────────────── */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<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">
<HardDrive className="h-4 w-4" />
</div>
{t('dsgvo.retention_settings')}
</CardTitle>
<CardDescription>{t('dsgvo.retention_settings_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'grades', icon: GraduationCap, color: 'emerald', defaultYears: 3 },
{ key: 'attendance', icon: CalendarCheck, color: 'teal', defaultYears: 2 },
{ key: 'behavior', icon: Heart, color: 'violet', defaultYears: 1 },
].map((retention) => {
const Icon = retention.icon;
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
emerald: { bg: 'bg-emerald-50/50 dark:bg-emerald-900/10', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/30 dark:border-emerald-900/20' },
teal: { bg: 'bg-teal-50/50 dark:bg-teal-900/10', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/30 dark:border-teal-900/20' },
violet: { bg: 'bg-violet-50/50 dark:bg-violet-900/10', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/30 dark:border-violet-900/20' },
};
const c = colorMap[retention.color] || colorMap.emerald;
return (
<div key={retention.key} className={`flex items-center justify-between p-4 rounded-xl ${c.bg} border ${c.border}`}>
<div className="flex items-center gap-3">
<Icon className={`h-5 w-5 ${c.text}`} />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t(`dsgvo.retention_${retention.key}`)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{retention.defaultYears}</span>
<span className="text-xs text-muted-foreground">{t('dsgvo.retention_years')}</span>
</div>
</div>
);
})}
</CardContent>
</Card>
{/* ── Cookie Consent Management Card ─────────────────────────────── */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-rose-400 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-rose-50/50 to-transparent dark:from-rose-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<Shield className="h-4 w-4" />
</div>
{t('dsgvo.cookie_settings')}
</CardTitle>
<CardDescription>{t('dsgvo.cookie_consent')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t('dsgvo.cookie_essential')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('dsgvo.cookie_essential_desc')}</p>
</div>
</div>
<Badge className="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 text-xs">
{t('dsgvo.consent_granted')}
</Badge>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-gray-50/50 dark:bg-gray-800/50 border border-gray-200/30 dark:border-gray-700/20">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<BarChart3 className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{t('dsgvo.cookie_analytics')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('dsgvo.cookie_analytics_desc')}</p>
</div>
</div>
<Badge className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs">
{t('dsgvo.consent_revoked')}
</Badge>
</div>
</CardContent>
</Card>
{/* ── Delete Account Confirmation Dialog ─────────────────────── */}
<AlertDialog open={deleteDialogOpen} onOpenChange={(open) => { if (!open) { setDeleteDialogOpen(false); setDeletePassword(''); } }}>
<AlertDialogContent>
+494
View File
@@ -999,6 +999,17 @@ const de: TranslationDict = {
'assessments.range': 'Spanne',
'assessments.no_scores': 'Keine Punktezahlen',
// ─── Scratch pad (assessments) ──────────────────────────────────────
'assessments.scratch_pad': 'Notizblock',
'assessments.scratch_pad_title': 'Notizblock — {title}',
'assessments.scratch_pad_description': 'Hier können Sie Ihre Lösung handschriftlich erarbeiten.',
'assessments.scratch_pad_save': 'Zeichnung speichern',
'assessments.scratch_pad_saved': 'Zeichnung gespeichert',
'assessments.scratch_pad_saving': 'Wird gespeichert…',
'assessments.scratch_pad_open': 'Notizblock öffnen',
'assessments.scratch_pad_close': 'Notizblock schließen',
'assessments.scratch_pad_resize': 'Größe anpassen',
// ─── Student journey timeline ──────────────────────────────────────
'student.journey': 'Lernreise',
'student.journey_milestone': 'Meilenstein',
@@ -2136,6 +2147,54 @@ const de: TranslationDict = {
'notebooks.type_dotted': 'Punktiert',
'notebooks.type_music': 'Notenlinien',
// ─── Page Templates ──────────────────────────────────────────────
'notebooks.page_template': 'Seitenvorlage',
'notebooks.page_template_cornell': 'Cornell-Notizen',
'notebooks.page_template_cornell_desc': 'Cornell-Notizsystem mit Zusammenfassung',
'notebooks.page_template_mindmap': 'Mind Map',
'notebooks.page_template_mindmap_desc': 'Zentrale Idee mit Verzweigungen',
'notebooks.page_template_venn': 'Venn-Diagramm',
'notebooks.page_template_venn_desc': 'Zwei Mengen vergleichen',
'notebooks.page_template_tchart': 'T-Diagramm',
'notebooks.page_template_tchart_desc': 'Zwei Seiten vergleichen',
'notebooks.page_template_weekly': 'Wochenplaner',
'notebooks.page_template_weekly_desc': 'Wochenuebersicht mit Aufgaben',
'notebooks.page_template_blank': 'Leere Seite',
'notebooks.page_template_choose': 'Seitenvorlage waehlen',
// ─── Sticky Notes ────────────────────────────────────────────────
'notebooks.sticky_note': 'Klebezettel',
'notebooks.sticky_note_add': 'Klebezettel hinzufuegen',
'notebooks.sticky_note_delete': 'Klebezettel entfernen',
'notebooks.sticky_note_color': 'Farbe',
'notebooks.sticky_note_edit': 'Klebezettel bearbeiten',
// ─── Search Within Notebook ──────────────────────────────────────
'notebooks.search_pages': 'Seiten durchsuchen',
'notebooks.search_pages_placeholder': 'Text auf allen Seiten suchen…',
'notebooks.search_no_results': 'Keine Ergebnisse gefunden',
'notebooks.search_results': 'Ergebnisse',
'notebooks.search_on_page': 'auf Seite',
// ─── Page Duplication ────────────────────────────────────────────
'notebooks.duplicate_page': 'Seite duplizieren',
'notebooks.duplicate_page_success': 'Seite dupliziert',
'notebooks.duplicate_page_error': 'Fehler beim Duplizieren der Seite',
// ─── Sections / Chapters ─────────────────────────────────────────
'notebooks.sections': 'Abschnitte',
'notebooks.section_add': 'Abschnitt hinzufuegen',
'notebooks.section_name': 'Abschnittsname',
'notebooks.section_name_placeholder': 'Abschnittsname eingeben',
'notebooks.section_delete': 'Abschnitt entfernen',
'notebooks.section_divider': 'Trennblatt',
'notebooks.table_of_contents': 'Inhaltsverzeichnis',
'notebooks.no_sections': 'Keine Abschnitte vorhanden',
// ─── Highlighter Tool ────────────────────────────────────────────
'drawing.tool_highlighter': 'Textmarker',
'drawing.tool_highlighter_desc': 'Halbtransparenter Textmarker zum Hervorheben',
// ─── Enhanced Notifications ──────────────────────────────────────
'notifications.today': 'Heute',
'notifications.yesterday': 'Gestern',
@@ -3499,6 +3558,194 @@ const de: TranslationDict = {
'dsgvo.right_objection_desc': 'Sie haben das Recht, aus Gründen der besonderen Situation Widerspruch gegen die Verarbeitung einzulegen.',
'dsgvo.dpa_download': 'AVV-Vorlage herunterladen',
'dsgvo.data_register_download': 'Verzeichnis herunterladen',
'dsgvo.consent_management': 'Einwilligungsverwaltung',
'dsgvo.consent_management_desc': 'Verwalten Sie Ihre Einwilligungen zur Datenverarbeitung.',
'dsgvo.consent_data_processing': 'Datenverarbeitung',
'dsgvo.consent_data_processing_desc': 'Einwilligung zur Verarbeitung Ihrer Daten für den Betrieb der Plattform.',
'dsgvo.consent_communication': 'Kommunikation',
'dsgvo.consent_communication_desc': 'Einwilligung zum Empfang von Benachrichtigungen und E-Mails.',
'dsgvo.consent_analytics': 'Anonyme Analyse',
'dsgvo.consent_analytics_desc': 'Einwilligung zur anonymen Nutzungsanalyse zur Verbesserung der Plattform.',
'dsgvo.consent_third_party': 'Datenweitergabe',
'dsgvo.consent_third_party_desc': 'Einwilligung zur Weitergabe von Daten an Dritte (z.B. Wettbewerbspartner).',
'dsgvo.consent_granted': 'Erteilt',
'dsgvo.consent_revoked': 'Widerrufen',
'dsgvo.consent_revoke': 'Widerrufen',
'dsgvo.consent_grant': 'Erteilen',
'dsgvo.retention_settings': 'Aufbewahrungseinstellungen',
'dsgvo.retention_settings_desc': 'Konfigurieren Sie die Speicherdauer Ihrer Daten.',
'dsgvo.retention_grades': 'Noten aufbewahren für',
'dsgvo.retention_attendance': 'Anwesenheitsdaten aufbewahren für',
'dsgvo.retention_behavior': 'Verhaltensdaten aufbewahren für',
'dsgvo.retention_years': 'Jahre',
// ─── Digital Reward Catalog ─────────────────────────────────────────
'rewards.title': 'Belohnungen',
'rewards.catalog': 'Belohnungskatalog',
'rewards.catalog_desc': 'Lösen Sie Ihre Punkte für tolle Belohnungen ein.',
'rewards.category.streaming': 'Streaming',
'rewards.category.shopping': 'Einkaufen',
'rewards.category.experience': 'Erlebnisse',
'rewards.category.merchandise': 'Schul-Merchandise',
'rewards.category.privilege': 'Sonderrechte',
'rewards.category.all': 'Alle Kategorien',
'rewards.points_balance': 'Punktestand',
'rewards.points_earned': 'Punkte verdient',
'rewards.points_spent': 'Punkte ausgegeben',
'rewards.redeem': 'Einlösen',
'rewards.redeem_confirm': 'Belohnung einlösen?',
'rewards.redeem_confirm_desc': 'Möchten Sie {points} Punkte für {reward} einlösen?',
'rewards.redeem_success': 'Belohnung erfolgreich eingelöst!',
'rewards.redeem_error': 'Fehler beim Einlösen der Belohnung.',
'rewards.insufficient_points': 'Nicht genügend Punkte.',
'rewards.out_of_stock': 'Nicht mehr verfügbar.',
'rewards.stock_remaining': 'Noch {count} verfügbar',
'rewards.unlimited': 'Unbegrenzt',
'rewards.points_cost': 'Kosten',
'rewards.my_redemptions': 'Meine Einlösungen',
'rewards.redemption_status': 'Status',
'rewards.redemption.pending': 'Ausstehend',
'rewards.redemption.approved': 'Genehmigt',
'rewards.redemption.rejected': 'Abgelehnt',
'rewards.redemption.fulfilled': 'Erfüllt',
'rewards.points_history': 'Punkteverlauf',
'rewards.points_source.competition': 'Wettbewerb',
'rewards.points_source.grade': 'Gute Noten',
'rewards.points_source.attendance': 'Anwesenheit',
'rewards.points_source.homework': 'Hausaufgaben',
'rewards.points_source.bonus': 'Bonus',
'rewards.create_reward': 'Belohnung erstellen',
'rewards.edit_reward': 'Belohnung bearbeiten',
'rewards.delete_reward': 'Belohnung löschen',
'rewards.no_rewards': 'Keine Belohnungen vorhanden',
'rewards.streaming.netflix': 'Netflix-Gutschein',
'rewards.streaming.spotify': 'Spotify-Premium',
'rewards.streaming.disney': 'Disney+-Gutschein',
'rewards.shopping.amazon': 'Amazon-Gutschein',
'rewards.shopping.thalia': 'Thalia-Gutschein',
'rewards.shopping.mediamarkt': 'MediaMarkt-Gutschein',
'rewards.experience.cinema': 'Kino-Ticket',
'rewards.experience.theater': 'Theater-Ticket',
'rewards.experience.concert': 'Konzert-Ticket',
'rewards.experience.museum': 'Museumseintritt',
'rewards.merchandise.tshirt': 'Schul-T-Shirt',
'rewards.merchandise.stickers': 'Aufkleber-Set',
'rewards.merchandise.notebook': 'Schulheft',
'rewards.privilege.homework_pass': 'Hausaufgaben-Frei',
'rewards.privilege.extra_break': 'Extra Pause',
'rewards.privilege.choose_seat': 'Sitzplatz wählen',
'rewards.earn_points': 'Punkte verdienen',
'rewards.earn_points_desc': 'Verdienen Sie Punkte durch aktive Teilnahme am Schulleben.',
'rewards.earn_competition': 'Wettbewerbe: Teilnahme + Gewinn',
'rewards.earn_grades': 'Gute Noten: Je besser, desto mehr Punkte',
'rewards.earn_attendance': 'Anwesenheitssträhne: Regelmäßig dabei sein',
'rewards.earn_homework': 'Hausaufgaben: Pünktlich abgeben',
'rewards.earn_bonus': 'Bonuspunkte: Besondere Leistungen',
// ─── Teacher Dashboard ──────────────────────────────────────────────
'teacher.dashboard_title': 'Lehrer-Dashboard',
'teacher.welcome_back': 'Willkommen zurück',
'teacher.my_classes': 'Meine Klassen',
'teacher.my_classes_desc': 'Übersicht Ihrer Klassen mit Schüleranzahl',
'teacher.recent_assessments': 'Letzte Bewertungen',
'teacher.graded': 'Benotet',
'teacher.pending': 'Ausstehend',
'teacher.upcoming_lessons': 'Kommende Stunden',
'teacher.competence_progress': 'Kompetenzfortschritt',
'teacher.competence_progress_desc': 'Radar-Übersicht der Kompetenzbereiche',
'teacher.recent_progress_entries': 'Neueste Fortschrittseinträge',
'teacher.quick_actions': 'Schnellaktionen',
'teacher.create_assessment': 'Bewertung erstellen',
'teacher.take_attendance': 'Anwesenheit erfassen',
'teacher.log_progress': 'Fortschritt loggen',
'teacher.message_parents': 'Eltern kontaktieren',
'teacher.student_count': 'Schüler',
'teacher.no_classes': 'Keine Klassen vorhanden',
'teacher.no_assessments': 'Keine Bewertungen vorhanden',
'teacher.no_lessons': 'Keine Stunden geplant',
'teacher.avg_mastery': 'Durchschnittsniveau',
'teacher.assessment_count': 'Anzahl Bewertungen',
'teacher.class_overview': 'Klassenübersicht',
// ─── Student Dashboard (enhanced) ───────────────────────────────────
'student.my_grades_overview': 'Meine Notenübersicht',
'student.my_grades_trend': 'Notenverlauf',
'student.grade_trend_up': 'Steigend',
'student.grade_trend_down': 'Fallend',
'student.grade_trend_stable': 'Stabil',
'student.my_competence_flower': 'Meine Kompetenzblume',
'student.competence_flower_desc': 'Visuelle Übersicht deiner Kompetenzentwicklung',
'student.upcoming_homework': 'Anstehende Hausaufgaben',
'student.no_homework': 'Keine anstehenden Hausaufgaben',
'student.competition_standings': 'Wettbewerbsstände',
'student.my_rank': 'Mein Rang',
'student.points': 'Punkte',
'student.no_competitions': 'Keine aktiven Wettbewerbe',
'student.school_announcements': 'Schulankündigungen',
// ─── Parent Dashboard (enhanced) ────────────────────────────────────
'parent.children_grades': 'Noten der Kinder',
'parent.children_grades_desc': 'Aktuelle Noten Ihrer Kinder',
'parent.child_grade': 'Note',
'parent.child_subject': 'Fach',
'parent.children_attendance_summary': 'Anwesenheitsübersicht der Kinder',
'parent.present_rate': 'Anwesenheitsrate',
'parent.absent_count': 'Fehltage',
'parent.late_count': 'Verspätungen',
'parent.teacher_messages': 'Lehrernachrichten',
'parent.no_teacher_messages': 'Keine Nachrichten von Lehrern',
'parent.school_announcements': 'Schulankündigungen',
'parent.calendar_events': 'Termine',
'parent.upcoming_events': 'Anstehende Termine',
'parent.no_events': 'Keine anstehenden Termine',
'parent.competition_results': 'Wettbewerbsergebnisse',
'parent.no_competition_results': 'Keine Wettbewerbsgebnisse verfügbar',
'parent.child_recent_grade': 'Letzte Note',
// ─── School Admin Dashboard ─────────────────────────────────────────
'admin.dashboard_title': 'Admin-Dashboard',
'admin.welcome_back': 'Willkommen zurück',
'admin.school_overview': 'Schulübersicht',
'admin.total_students': 'Schüler insgesamt',
'admin.total_classes': 'Klassen insgesamt',
'admin.total_teachers': 'Lehrkräfte insgesamt',
'admin.total_parents': 'Eltern insgesamt',
'admin.system_health': 'Systemstatus',
'admin.system_healthy': 'System läuft einwandfrei',
'admin.system_degraded': 'Eingeschränkter Betrieb',
'admin.uptime': 'Betriebszeit',
'admin.active_users': 'Aktive Nutzer',
'admin.storage_used': 'Speicherbelegung',
'admin.api_requests': 'API-Anfragen heute',
'admin.audit_log': 'Aktivitätsprotokoll',
'admin.audit_log_desc': 'Letzte Systemaktivitäten',
'admin.no_audit_entries': 'Keine Aktivitäten protokolliert',
'admin.user_management': 'Benutzerverwaltung',
'admin.manage_teachers': 'Lehrkräfte verwalten',
'admin.manage_students': 'Schüler verwalten',
'admin.manage_parents': 'Eltern verwalten',
'admin.school_statistics': 'Schulstatistiken',
'admin.enrollment_trend': 'Einschreibungsentwicklung',
'admin.grade_distribution': 'Notenverteilung',
'admin.attendance_rate': 'Anwesenheitsrate',
'admin.competency_overview': 'Kompetenzübersicht',
'admin.announcements_management': 'Ankündigungen verwalten',
'admin.create_announcement': 'Ankündigung erstellen',
'admin.recent_activity': 'Letzte Aktivitäten',
'admin.action_type': 'Aktionstyp',
'admin.user': 'Benutzer',
'admin.timestamp': 'Zeitstempel',
'admin.details': 'Details',
'admin.quick_actions': 'Schnellaktionen',
'admin.add_teacher': 'Lehrkraft hinzufügen',
'admin.add_student': 'Schüler hinzufügen',
'admin.add_class': 'Klasse hinzufügen',
'admin.school_settings': 'Schuleinstellungen',
'admin.export_data': 'Daten exportieren',
'admin.health_excellent': 'Ausgezeichnet',
'admin.health_good': 'Gut',
'admin.health_warning': 'Warnung',
'admin.health_critical': 'Kritisch',
};
const en: TranslationDict = {
@@ -4497,6 +4744,17 @@ const en: TranslationDict = {
'assessments.range': 'Range',
'assessments.no_scores': 'No scores available',
// ─── Scratch pad (assessments) ──────────────────────────────────────
'assessments.scratch_pad': 'Scratch Pad',
'assessments.scratch_pad_title': 'Scratch Pad — {title}',
'assessments.scratch_pad_description': 'Work out your solution by hand here.',
'assessments.scratch_pad_save': 'Save Drawing',
'assessments.scratch_pad_saved': 'Drawing saved',
'assessments.scratch_pad_saving': 'Saving…',
'assessments.scratch_pad_open': 'Open Scratch Pad',
'assessments.scratch_pad_close': 'Close Scratch Pad',
'assessments.scratch_pad_resize': 'Resize',
// ─── Student journey timeline ──────────────────────────────────────
'student.journey': 'Learning Journey',
'student.journey_milestone': 'Milestone',
@@ -5634,6 +5892,54 @@ const en: TranslationDict = {
'notebooks.type_dotted': 'Dotted',
'notebooks.type_music': 'Music Lines',
// ─── Page Templates ──────────────────────────────────────────────
'notebooks.page_template': 'Page Template',
'notebooks.page_template_cornell': 'Cornell Notes',
'notebooks.page_template_cornell_desc': 'Cornell note-taking system with summary',
'notebooks.page_template_mindmap': 'Mind Map',
'notebooks.page_template_mindmap_desc': 'Central idea with branches',
'notebooks.page_template_venn': 'Venn Diagram',
'notebooks.page_template_venn_desc': 'Compare two sets',
'notebooks.page_template_tchart': 'T-Chart',
'notebooks.page_template_tchart_desc': 'Compare two sides',
'notebooks.page_template_weekly': 'Weekly Planner',
'notebooks.page_template_weekly_desc': 'Weekly overview with tasks',
'notebooks.page_template_blank': 'Blank Page',
'notebooks.page_template_choose': 'Choose page template',
// ─── Sticky Notes ────────────────────────────────────────────────
'notebooks.sticky_note': 'Sticky Note',
'notebooks.sticky_note_add': 'Add Sticky Note',
'notebooks.sticky_note_delete': 'Remove Sticky Note',
'notebooks.sticky_note_color': 'Color',
'notebooks.sticky_note_edit': 'Edit Sticky Note',
// ─── Search Within Notebook ──────────────────────────────────────
'notebooks.search_pages': 'Search Pages',
'notebooks.search_pages_placeholder': 'Search text across all pages…',
'notebooks.search_no_results': 'No results found',
'notebooks.search_results': 'Results',
'notebooks.search_on_page': 'on page',
// ─── Page Duplication ────────────────────────────────────────────
'notebooks.duplicate_page': 'Duplicate Page',
'notebooks.duplicate_page_success': 'Page duplicated',
'notebooks.duplicate_page_error': 'Error duplicating page',
// ─── Sections / Chapters ─────────────────────────────────────────
'notebooks.sections': 'Sections',
'notebooks.section_add': 'Add Section',
'notebooks.section_name': 'Section Name',
'notebooks.section_name_placeholder': 'Enter section name',
'notebooks.section_delete': 'Remove Section',
'notebooks.section_divider': 'Divider',
'notebooks.table_of_contents': 'Table of Contents',
'notebooks.no_sections': 'No sections yet',
// ─── Highlighter Tool ────────────────────────────────────────────
'drawing.tool_highlighter': 'Highlighter',
'drawing.tool_highlighter_desc': 'Semi-transparent highlighter for emphasizing',
// ─── Enhanced Notifications ──────────────────────────────────────
'notifications.today': 'Today',
'notifications.yesterday': 'Yesterday',
@@ -6997,6 +7303,194 @@ const en: TranslationDict = {
'dsgvo.right_objection_desc': 'You have the right to object to processing on grounds relating to your particular situation.',
'dsgvo.dpa_download': 'Download DPA template',
'dsgvo.data_register_download': 'Download register',
'dsgvo.consent_management': 'Consent Management',
'dsgvo.consent_management_desc': 'Manage your data processing consents.',
'dsgvo.consent_data_processing': 'Data Processing',
'dsgvo.consent_data_processing_desc': 'Consent to process your data for platform operation.',
'dsgvo.consent_communication': 'Communication',
'dsgvo.consent_communication_desc': 'Consent to receive notifications and emails.',
'dsgvo.consent_analytics': 'Anonymous Analytics',
'dsgvo.consent_analytics_desc': 'Consent to anonymous usage analytics for platform improvement.',
'dsgvo.consent_third_party': 'Third-Party Sharing',
'dsgvo.consent_third_party_desc': 'Consent to share data with third parties (e.g., competition partners).',
'dsgvo.consent_granted': 'Granted',
'dsgvo.consent_revoked': 'Revoked',
'dsgvo.consent_revoke': 'Revoke',
'dsgvo.consent_grant': 'Grant',
'dsgvo.retention_settings': 'Retention Settings',
'dsgvo.retention_settings_desc': 'Configure how long your data is retained.',
'dsgvo.retention_grades': 'Retain grades for',
'dsgvo.retention_attendance': 'Retain attendance data for',
'dsgvo.retention_behavior': 'Retain behavior data for',
'dsgvo.retention_years': 'years',
// ─── Digital Reward Catalog ─────────────────────────────────────────
'rewards.title': 'Rewards',
'rewards.catalog': 'Reward Catalog',
'rewards.catalog_desc': 'Redeem your points for great rewards.',
'rewards.category.streaming': 'Streaming',
'rewards.category.shopping': 'Shopping',
'rewards.category.experience': 'Experiences',
'rewards.category.merchandise': 'School Merchandise',
'rewards.category.privilege': 'Special Privileges',
'rewards.category.all': 'All Categories',
'rewards.points_balance': 'Points Balance',
'rewards.points_earned': 'Points Earned',
'rewards.points_spent': 'Points Spent',
'rewards.redeem': 'Redeem',
'rewards.redeem_confirm': 'Redeem Reward?',
'rewards.redeem_confirm_desc': 'Do you want to spend {points} points for {reward}?',
'rewards.redeem_success': 'Reward redeemed successfully!',
'rewards.redeem_error': 'Error redeeming reward.',
'rewards.insufficient_points': 'Insufficient points.',
'rewards.out_of_stock': 'Out of stock.',
'rewards.stock_remaining': '{count} remaining',
'rewards.unlimited': 'Unlimited',
'rewards.points_cost': 'Cost',
'rewards.my_redemptions': 'My Redemptions',
'rewards.redemption_status': 'Status',
'rewards.redemption.pending': 'Pending',
'rewards.redemption.approved': 'Approved',
'rewards.redemption.rejected': 'Rejected',
'rewards.redemption.fulfilled': 'Fulfilled',
'rewards.points_history': 'Points History',
'rewards.points_source.competition': 'Competition',
'rewards.points_source.grade': 'Good Grades',
'rewards.points_source.attendance': 'Attendance',
'rewards.points_source.homework': 'Homework',
'rewards.points_source.bonus': 'Bonus',
'rewards.create_reward': 'Create Reward',
'rewards.edit_reward': 'Edit Reward',
'rewards.delete_reward': 'Delete Reward',
'rewards.no_rewards': 'No rewards available',
'rewards.streaming.netflix': 'Netflix Voucher',
'rewards.streaming.spotify': 'Spotify Premium',
'rewards.streaming.disney': 'Disney+ Voucher',
'rewards.shopping.amazon': 'Amazon Voucher',
'rewards.shopping.thalia': 'Thalia Voucher',
'rewards.shopping.mediamarkt': 'MediaMarkt Voucher',
'rewards.experience.cinema': 'Cinema Ticket',
'rewards.experience.theater': 'Theater Ticket',
'rewards.experience.concert': 'Concert Ticket',
'rewards.experience.museum': 'Museum Entry',
'rewards.merchandise.tshirt': 'School T-Shirt',
'rewards.merchandise.stickers': 'Sticker Set',
'rewards.merchandise.notebook': 'School Notebook',
'rewards.privilege.homework_pass': 'Homework Pass',
'rewards.privilege.extra_break': 'Extra Break Time',
'rewards.privilege.choose_seat': 'Choose Your Seat',
'rewards.earn_points': 'Earn Points',
'rewards.earn_points_desc': 'Earn points through active participation in school life.',
'rewards.earn_competition': 'Competitions: Participation + Wins',
'rewards.earn_grades': 'Good Grades: Better grades earn more points',
'rewards.earn_attendance': 'Attendance Streak: Be there regularly',
'rewards.earn_homework': 'Homework: Submit on time',
'rewards.earn_bonus': 'Bonus Points: Special achievements',
// ─── Teacher Dashboard ──────────────────────────────────────────────
'teacher.dashboard_title': 'Teacher Dashboard',
'teacher.welcome_back': 'Welcome back',
'teacher.my_classes': 'My Classes',
'teacher.my_classes_desc': 'Overview of your classes with student count',
'teacher.recent_assessments': 'Recent Assessments',
'teacher.graded': 'Graded',
'teacher.pending': 'Pending',
'teacher.upcoming_lessons': 'Upcoming Lessons',
'teacher.competence_progress': 'Competence Progress',
'teacher.competence_progress_desc': 'Radar overview of competence areas',
'teacher.recent_progress_entries': 'Recent Progress Entries',
'teacher.quick_actions': 'Quick Actions',
'teacher.create_assessment': 'Create Assessment',
'teacher.take_attendance': 'Take Attendance',
'teacher.log_progress': 'Log Progress',
'teacher.message_parents': 'Message Parents',
'teacher.student_count': 'Students',
'teacher.no_classes': 'No classes available',
'teacher.no_assessments': 'No assessments available',
'teacher.no_lessons': 'No lessons scheduled',
'teacher.avg_mastery': 'Average Mastery',
'teacher.assessment_count': 'Assessment Count',
'teacher.class_overview': 'Class Overview',
// ─── Student Dashboard (enhanced) ───────────────────────────────────
'student.my_grades_overview': 'My Grades Overview',
'student.my_grades_trend': 'Grade Trend',
'student.grade_trend_up': 'Improving',
'student.grade_trend_down': 'Declining',
'student.grade_trend_stable': 'Stable',
'student.my_competence_flower': 'My Competence Flower',
'student.competence_flower_desc': 'Visual overview of your competence development',
'student.upcoming_homework': 'Upcoming Homework',
'student.no_homework': 'No upcoming homework',
'student.competition_standings': 'Competition Standings',
'student.my_rank': 'My Rank',
'student.points': 'Points',
'student.no_competitions': 'No active competitions',
'student.school_announcements': 'School Announcements',
// ─── Parent Dashboard (enhanced) ────────────────────────────────────
'parent.children_grades': "Children's Grades",
'parent.children_grades_desc': "Current grades of your children",
'parent.child_grade': 'Grade',
'parent.child_subject': 'Subject',
'parent.children_attendance_summary': "Children's Attendance Summary",
'parent.present_rate': 'Attendance Rate',
'parent.absent_count': 'Absences',
'parent.late_count': 'Late Arrivals',
'parent.teacher_messages': 'Teacher Messages',
'parent.no_teacher_messages': 'No messages from teachers',
'parent.school_announcements': 'School Announcements',
'parent.calendar_events': 'Calendar Events',
'parent.upcoming_events': 'Upcoming Events',
'parent.no_events': 'No upcoming events',
'parent.competition_results': 'Competition Results',
'parent.no_competition_results': 'No competition results available',
'parent.child_recent_grade': 'Recent Grade',
// ─── School Admin Dashboard ─────────────────────────────────────────
'admin.dashboard_title': 'Admin Dashboard',
'admin.welcome_back': 'Welcome back',
'admin.school_overview': 'School Overview',
'admin.total_students': 'Total Students',
'admin.total_classes': 'Total Classes',
'admin.total_teachers': 'Total Teachers',
'admin.total_parents': 'Total Parents',
'admin.system_health': 'System Health',
'admin.system_healthy': 'System running smoothly',
'admin.system_degraded': 'Degraded performance',
'admin.uptime': 'Uptime',
'admin.active_users': 'Active Users',
'admin.storage_used': 'Storage Used',
'admin.api_requests': 'API Requests Today',
'admin.audit_log': 'Audit Log',
'admin.audit_log_desc': 'Recent system activities',
'admin.no_audit_entries': 'No activities logged',
'admin.user_management': 'User Management',
'admin.manage_teachers': 'Manage Teachers',
'admin.manage_students': 'Manage Students',
'admin.manage_parents': 'Manage Parents',
'admin.school_statistics': 'School Statistics',
'admin.enrollment_trend': 'Enrollment Trend',
'admin.grade_distribution': 'Grade Distribution',
'admin.attendance_rate': 'Attendance Rate',
'admin.competency_overview': 'Competency Overview',
'admin.announcements_management': 'Announcements Management',
'admin.create_announcement': 'Create Announcement',
'admin.recent_activity': 'Recent Activity',
'admin.action_type': 'Action Type',
'admin.user': 'User',
'admin.timestamp': 'Timestamp',
'admin.details': 'Details',
'admin.quick_actions': 'Quick Actions',
'admin.add_teacher': 'Add Teacher',
'admin.add_student': 'Add Student',
'admin.add_class': 'Add Class',
'admin.school_settings': 'School Settings',
'admin.export_data': 'Export Data',
'admin.health_excellent': 'Excellent',
'admin.health_good': 'Good',
'admin.health_warning': 'Warning',
'admin.health_critical': 'Critical',
};
const translations: Record<string, TranslationDict> = { de, en };