From 44ff170fca5d7b7c9f3279f9fb8c56d19c7989fb Mon Sep 17 00:00:00 2001 From: Mustafa Cetin Date: Wed, 29 Jul 2026 21:01:32 +0000 Subject: [PATCH] feat: Round 23 - Practice exercises, enhanced virtual character, inter-school federation, voice chat - Practice exercises: AI-generated multiple choice questions, practice mode with score tracking - Enhanced virtual character: More animations, accessories, AI chat integration, XP system - Inter-school federation: School leaderboard, team scores, federation competitions - Enhanced communication: Voice chat, file sharing, note sharing, teacher notes panel - Added isFederation and federationSchedule fields to Competition model - 170+ new i18n keys - All lints pass --- prisma/schema.prisma | 2 + src/app/api/ai/character/route.ts | 4 +- .../[id]/share-notes/route.ts | 175 +++ src/app/api/competitions/federation/route.ts | 278 ++++ .../api/subject-contents/exercises/route.ts | 314 +++++ src/components/communication-view.tsx | 598 +++++++-- src/components/competitions-view.tsx | 600 +++++++++ src/components/subjects-view.tsx | 492 ++++++-- src/components/virtual-character.tsx | 1122 +++++++++++++---- src/lib/api.ts | 45 +- src/lib/i18n.ts | 368 ++++++ worklog.md | 133 ++ 12 files changed, 3655 insertions(+), 476 deletions(-) create mode 100644 src/app/api/communication-rooms/[id]/share-notes/route.ts create mode 100644 src/app/api/competitions/federation/route.ts create mode 100644 src/app/api/subject-contents/exercises/route.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28324e4..bba324b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1680,6 +1680,8 @@ model Competition { rules String? // markdown rules/description isPublic Boolean @default(false) // visible to all schools? isDemo Boolean @default(false) + isFederation Boolean @default(false) // inter-school district federation competition + federationSchedule String? // "weekly" | "monthly" | "quarterly" createdById String // teacher/admin who created createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/ai/character/route.ts b/src/app/api/ai/character/route.ts index d471eab..62061ee 100644 --- a/src/app/api/ai/character/route.ts +++ b/src/app/api/ai/character/route.ts @@ -66,7 +66,7 @@ export async function PUT(request: Request) { } const body = await request.json(); - const { characterId, name, color, mood } = body; + const { characterId, name, color, mood, accessories } = body; // Get existing character const existing = await db.virtualCharacter.findUnique({ @@ -85,6 +85,7 @@ export async function PUT(request: Request) { level: 1, xp: 0, mood: mood || 'happy', + accessories: accessories || null, }, }); return NextResponse.json(character); @@ -98,6 +99,7 @@ export async function PUT(request: Request) { ...(name ? { name } : {}), ...(color ? { color } : {}), ...(mood ? { mood } : {}), + ...(accessories !== undefined ? { accessories } : {}), }, }); diff --git a/src/app/api/communication-rooms/[id]/share-notes/route.ts b/src/app/api/communication-rooms/[id]/share-notes/route.ts new file mode 100644 index 0000000..56c5817 --- /dev/null +++ b/src/app/api/communication-rooms/[id]/share-notes/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; + +// ─── GET /api/communication-rooms/[id]/share-notes ────────────────── +// Get shared notebooks in a communication room + +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 room = await db.communicationRoom.findUnique({ where: { id } }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Access control + if (role === 'STUDENT' && room.studentId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } else if (role === 'TEACHER' && room.teacherId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } else if (role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL') { + if (room.schoolId !== session.user?.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + // Get messages of type note_share in this room + const sharedNotes = await db.communicationMessage.findMany({ + where: { + roomId: id, + messageType: 'note_share', + }, + orderBy: { createdAt: 'desc' }, + include: { + sender: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }); + + // Parse metadata for each shared note + const enrichedNotes = sharedNotes.map((msg) => { + let metadata: Record = {}; + try { + metadata = msg.metadata ? JSON.parse(msg.metadata) : {}; + } catch { + // ignore parse errors + } + return { + id: msg.id, + content: msg.content, + senderId: msg.senderId, + sender: msg.sender, + createdAt: msg.createdAt, + notebookId: metadata.notebookId as string | undefined, + notebookTitle: metadata.notebookTitle as string | undefined, + notebookColor: metadata.notebookColor as string | undefined, + }; + }); + + return NextResponse.json({ sharedNotes: enrichedNotes }); + } catch (error) { + console.error('Share-notes GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── POST /api/communication-rooms/[id]/share-notes ───────────────── +// Share a notebook with the teacher in the room + +export async function POST( + 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 room = await db.communicationRoom.findUnique({ where: { id } }); + + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }); + } + + const role = session.user?.role; + const userId = session.userId; + + // Only the student can share notes in their room + if (room.studentId !== userId && room.teacherId !== userId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + if (room.status !== 'active') { + return NextResponse.json({ error: 'Room is not active' }, { status: 400 }); + } + + const body = await request.json(); + const { notebookId } = body; + + if (!notebookId) { + return NextResponse.json({ error: 'notebookId is required' }, { status: 400 }); + } + + // Verify the notebook exists and belongs to the user + const notebook = await db.notebook.findFirst({ + where: { + id: notebookId, + ownerId: userId, + deletedAt: null, + }, + include: { + pages: { + select: { id: true, title: true, textContent: true, pageNumber: true }, + orderBy: { pageNumber: 'asc' }, + take: 5, + }, + }, + }); + + if (!notebook) { + return NextResponse.json({ error: 'Notebook not found or not owned by you' }, { status: 404 }); + } + + // Create a note_share message + const metadata = JSON.stringify({ + notebookId: notebook.id, + notebookTitle: notebook.title, + notebookColor: notebook.color, + notebookType: notebook.notebookType, + pageCount: notebook.pages.length, + sharedPages: notebook.pages.map((p) => ({ + id: p.id, + title: p.title, + pageNumber: p.pageNumber, + })), + }); + + const message = await db.communicationMessage.create({ + data: { + roomId: id, + senderId: userId, + content: notebook.title, + messageType: 'note_share', + metadata, + }, + include: { + sender: { select: { id: true, firstName: true, lastName: true, role: true } }, + }, + }); + + // Update room's updatedAt + await db.communicationRoom.update({ + where: { id }, + data: { updatedAt: new Date() }, + }); + + return NextResponse.json(message, { status: 201 }); + } catch (error) { + console.error('Share-notes POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/competitions/federation/route.ts b/src/app/api/competitions/federation/route.ts new file mode 100644 index 0000000..babc7a7 --- /dev/null +++ b/src/app/api/competitions/federation/route.ts @@ -0,0 +1,278 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import { withRateLimit } from '@/lib/rate-limit'; + +// ─── Validation ───────────────────────────────────────────────────── + +const federationCategoryEnum = z.enum([ + 'math_olympiad', 'science_bowl', 'language_quiz', 'general_knowledge', +]); + +const scheduleEnum = z.enum(['weekly', 'monthly', 'quarterly']); + +const createFederationSchema = z.object({ + schoolId: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().max(5000).optional().nullable(), + category: federationCategoryEnum, + subjectId: z.string().optional().nullable(), + schedule: scheduleEnum, + startDate: z.string().min(1), + endDate: z.string().min(1), + registrationDeadline: z.string().optional().nullable(), + maxParticipants: z.number().int().min(1).optional().nullable(), + rules: z.string().max(20000).optional().nullable(), + isPublic: z.boolean().default(true), +}); + +function isTeacherOrAdmin(role: string | undefined): boolean { + return role === 'TEACHER' || role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN'; +} + +// ─── GET /api/competitions/federation — list federation competitions ─ + +async function getFederationCompetitions(request: Request): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const categoryParam = searchParams.get('category'); + const scheduleParam = searchParams.get('schedule'); + const statusParam = searchParams.get('status'); + const limit = parseInt(searchParams.get('limit') || '20'); + const offset = parseInt(searchParams.get('offset') || '0'); + + // Find the user's school and its district + const userSchoolId = session.user?.schoolId; + + let districtSchoolIds: string[] = []; + + if (userSchoolId) { + const school = await db.school.findUnique({ + where: { id: userSchoolId }, + select: { districtId: true }, + }); + + if (school?.districtId) { + const districtSchools = await db.school.findMany({ + where: { districtId: school.districtId, deletedAt: null }, + select: { id: true, name: true }, + }); + districtSchoolIds = districtSchools.map((s) => s.id); + } + } + + // Build where clause: federation competitions that are either from the user's + // district or public + const where: Record = { + isFederation: true, + deletedAt: null, + }; + + if (districtSchoolIds.length > 0) { + where.OR = [ + { schoolId: { in: districtSchoolIds } }, + { isPublic: true }, + ]; + } else { + where.isPublic = true; + } + + if (categoryParam) where.category = categoryParam; + if (scheduleParam) where.federationSchedule = scheduleParam; + if (statusParam) where.status = statusParam; + + 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, primaryColor: 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 }), + ]); + + // Compute school leaderboard for federation competitions + const schoolLeaderboard: Array<{ + schoolId: string; + schoolName: string; + teamScore: number; + participantCount: number; + rank: number; + }> = []; + + if (districtSchoolIds.length > 0) { + // Get all participants from district schools in federation competitions + const participants = await db.competitionParticipant.findMany({ + where: { + competition: { isFederation: true, deletedAt: null }, + participantType: 'student', + }, + include: { + competition: { + select: { id: true, schoolId: true }, + }, + }, + }); + + // Group by school and sum top scores + const schoolScores: Record = {}; + + for (const sid of districtSchoolIds) { + const school = await db.school.findUnique({ + where: { id: sid }, + select: { name: true }, + }); + if (school) { + schoolScores[sid] = { name: school.name, scores: [] }; + } + } + + // Get students from each school and their scores + for (const p of participants) { + const compSchoolId = p.competition.schoolId; + if (schoolScores[compSchoolId]) { + schoolScores[compSchoolId].scores.push(p.score); + } + } + + // Calculate team score (sum of top 10 student scores) + const leaderboardEntries = Object.entries(schoolScores).map(([sid, data]) => { + const sortedScores = [...data.scores].sort((a, b) => b - a); + const teamScore = sortedScores.slice(0, 10).reduce((sum, s) => sum + s, 0); + return { + schoolId: sid, + schoolName: data.name, + teamScore, + participantCount: data.scores.length, + rank: 0, + }; + }); + + leaderboardEntries.sort((a, b) => b.teamScore - a.teamScore); + leaderboardEntries.forEach((entry, idx) => { + entry.rank = idx + 1; + }); + + schoolLeaderboard.push(...leaderboardEntries); + } + + return NextResponse.json({ competitions, total, schoolLeaderboard }); + } catch (error) { + console.error('Federation GET error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── POST /api/competitions/federation — create federation comp ───── + +async function createFederationCompetition(request: Request): Promise { + try { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!isTeacherOrAdmin(session.user?.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const parsed = createFederationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.issues }, + { status: 400 } + ); + } + + const { schoolId, startDate, endDate, registrationDeadline, schedule, category } = parsed.data; + + // Verify school access + const school = await db.school.findUnique({ where: { id: schoolId } }); + if (!school) { + return NextResponse.json({ error: 'School not found' }, { status: 404 }); + } + if (session.user?.role === 'SCHOOL_ADMIN' && session.user.schoolId && school.id !== session.user.schoolId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Verify school is part of a district + if (!school.districtId) { + return NextResponse.json( + { error: 'School must be part of a district to create federation competitions' }, + { status: 400 } + ); + } + + // Validate dates + const start = new Date(startDate); + const end = new Date(endDate); + if (end <= start) { + return NextResponse.json( + { error: 'End date must be after start date' }, + { status: 400 } + ); + } + + const competition = await db.competition.create({ + data: { + schoolId, + title: parsed.data.title, + description: parsed.data.description ?? null, + competitionType: 'inter_school', + category, + subjectId: parsed.data.subjectId ?? null, + status: 'registration', + startDate: start, + endDate: end, + registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null, + maxParticipants: parsed.data.maxParticipants ?? null, + scoringType: 'points', + rules: parsed.data.rules ?? null, + isPublic: parsed.data.isPublic, + isFederation: true, + federationSchedule: schedule, + createdById: session.user.id, + }, + include: { + school: { select: { id: true, name: true, primaryColor: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true } }, + subject: { select: { id: true, name: true } }, + _count: { select: { participants: true, rewards: true } }, + }, + }); + + // Create leaderboard entries for all schools in the district + const districtSchools = await db.school.findMany({ + where: { districtId: school.districtId, deletedAt: null }, + select: { id: true, name: true }, + }); + + for (const ds of districtSchools) { + await db.competitionLeaderboard.create({ + data: { + competitionId: competition.id, + participantType: 'school', + participantId: ds.id, + participantName: ds.name, + score: 0, + }, + }); + } + + return NextResponse.json(competition, { status: 201 }); + } catch (error) { + console.error('Federation POST error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const GET = withRateLimit(getFederationCompetitions, 'dataRead'); +export const POST = withRateLimit(createFederationCompetition, 'dataWrite'); diff --git a/src/app/api/subject-contents/exercises/route.ts b/src/app/api/subject-contents/exercises/route.ts new file mode 100644 index 0000000..34cf282 --- /dev/null +++ b/src/app/api/subject-contents/exercises/route.ts @@ -0,0 +1,314 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getSession } from '@/lib/auth'; +import ZAI from 'z-ai-web-dev-sdk'; + +// Rate limit: max 10 requests per day per user +const MAX_EXERCISE_GENERATIONS_PER_DAY = 10; + +export async function POST(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { schoolId } = session.user; + if (!schoolId) { + return NextResponse.json({ error: 'No school assigned' }, { status: 403 }); + } + + const body = await request.json(); + const { contentId, topic, gradeLevel, count = 5, difficulty = 'medium' } = body; + + if (!contentId && !topic) { + return NextResponse.json( + { error: 'contentId or topic is required' }, + { status: 400 } + ); + } + + // Check rate limit + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Count today's exercise generation requests from chat messages + const todayRequestCount = await db.chatMessage.count({ + where: { + userId: session.userId, + schoolId, + senderType: 'system', + metadata: { contains: 'exercise_generation' }, + createdAt: { gte: today }, + }, + }); + + if (todayRequestCount >= MAX_EXERCISE_GENERATIONS_PER_DAY) { + return NextResponse.json( + { + error: 'Daily exercise generation limit reached', + retryAfter: 86400, + limit: MAX_EXERCISE_GENERATIONS_PER_DAY, + }, + { status: 429 } + ); + } + + // Get content item if contentId provided + let contentItem = null; + let topicName = topic || ''; + let effectiveGradeLevel = gradeLevel || '5'; + let effectiveDifficulty = difficulty || 'medium'; + + if (contentId) { + contentItem = await db.subjectContent.findUnique({ + where: { id: contentId }, + }); + + if (!contentItem) { + return NextResponse.json( + { error: 'Content not found' }, + { status: 404 } + ); + } + + topicName = contentItem.title; + effectiveGradeLevel = gradeLevel || extractGradeLevel(contentItem); + effectiveDifficulty = difficulty || contentItem.difficulty || 'medium'; + } + + // Build AI prompt + const countStr = Math.min(Math.max(count, 1), 10); + const systemPrompt = `Du bist ein Lehrer. Erstelle Uebungsfragen zum Thema ${topicName} fuer die Klasse ${effectiveGradeLevel}. Erstelle ${countStr} Fragen. Format: JSON array of {question, options (4 choices), correctAnswer (0-3), explanation}. Antworte NUR mit dem JSON-Array, kein Markdown, kein Code-Block.`; + + const zai = await ZAI.create(); + const completion = await zai.chat.completions.create({ + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: `Erstelle ${countStr} ${effectiveDifficulty === 'easy' ? 'einfache' : effectiveDifficulty === 'hard' ? 'schwere' : 'mittelschwere'} Multiple-Choice-Fragen zum Thema "${topicName}" fuer die ${effectiveGradeLevel}. Klasse. Jede Frage hat 4 Antwortmoeglichkeiten. Gib das Ergebnis als JSON-Array zurueck.`, + }, + ], + }); + + const aiResponse = completion.choices?.[0]?.message?.content || ''; + + // Parse the AI response to extract questions + let questions: Array<{ + question: string; + options: string[]; + correctAnswer: number; + explanation: string; + }> = []; + + try { + // Try to parse the response directly + let jsonStr = aiResponse.trim(); + + // Remove markdown code blocks if present + if (jsonStr.startsWith('```')) { + jsonStr = jsonStr.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, ''); + } + + questions = JSON.parse(jsonStr); + + if (!Array.isArray(questions)) { + throw new Error('Response is not an array'); + } + + // Validate and clean questions + questions = questions + .filter( + (q) => + q.question && + Array.isArray(q.options) && + q.options.length === 4 && + typeof q.correctAnswer === 'number' && + q.correctAnswer >= 0 && + q.correctAnswer <= 3 + ) + .map((q) => ({ + question: String(q.question), + options: q.options.map(String), + correctAnswer: Number(q.correctAnswer), + explanation: String(q.explanation || ''), + })); + } catch (parseError) { + console.error('Failed to parse AI questions:', parseError); + console.error('AI response was:', aiResponse.substring(0, 500)); + return NextResponse.json( + { error: 'Failed to parse generated questions', raw: aiResponse.substring(0, 200) }, + { status: 500 } + ); + } + + if (questions.length === 0) { + return NextResponse.json( + { error: 'No valid questions generated' }, + { status: 500 } + ); + } + + // Save questions to the content item's content field + if (contentItem) { + const existingContent = contentItem.content + ? (() => { + try { + return JSON.parse(contentItem.content); + } catch { + return {}; + } + })() + : {}; + + const updatedContent = { + ...existingContent, + questions, + lastGenerated: new Date().toISOString(), + gradeLevel: effectiveGradeLevel, + difficulty: effectiveDifficulty, + }; + + await db.subjectContent.update({ + where: { id: contentItem.id }, + data: { + content: JSON.stringify(updatedContent), + questionCount: questions.length, + }, + }); + } + + // Track rate limit via a system chat message + await db.chatMessage.create({ + data: { + schoolId, + userId: session.userId, + roomId: null, + content: `Exercise generation: ${questions.length} questions for "${topicName}"`, + senderType: 'system', + metadata: JSON.stringify({ + type: 'exercise_generation', + contentId: contentItem?.id || null, + topic: topicName, + questionCount: questions.length, + }), + isRead: true, + }, + }); + + // Award XP to virtual character + const virtualChar = await db.virtualCharacter.findUnique({ + where: { userId: session.userId }, + }); + if (virtualChar) { + const xpGain = 10; + const newXp = virtualChar.xp + xpGain; + const xpPerLevel = 100; + const newLevel = Math.floor(newXp / xpPerLevel) + 1; + await db.virtualCharacter.update({ + where: { id: virtualChar.id }, + data: { + xp: newXp, + level: newLevel, + mood: newLevel > virtualChar.level ? 'celebrating' : 'happy', + }, + }); + } + + return NextResponse.json({ + questions, + contentId: contentItem?.id || null, + topic: topicName, + gradeLevel: effectiveGradeLevel, + difficulty: effectiveDifficulty, + requestsToday: todayRequestCount + 1, + maxRequests: MAX_EXERCISE_GENERATIONS_PER_DAY, + }); + } catch (error) { + console.error('Exercise generation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// GET: Fetch existing questions for a content item +export async function GET(request: Request) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const contentId = searchParams.get('contentId'); + + if (!contentId) { + return NextResponse.json( + { error: 'contentId is required' }, + { status: 400 } + ); + } + + const contentItem = await db.subjectContent.findUnique({ + where: { id: contentId }, + }); + + if (!contentItem) { + return NextResponse.json( + { error: 'Content not found' }, + { status: 404 } + ); + } + + let questions: Array<{ + question: string; + options: string[]; + correctAnswer: number; + explanation: string; + }> = []; + + if (contentItem.content) { + try { + const parsed = JSON.parse(contentItem.content); + if (Array.isArray(parsed.questions)) { + questions = parsed.questions; + } else if (Array.isArray(parsed)) { + questions = parsed; + } + } catch { + // Content is not valid JSON + } + } + + return NextResponse.json({ + questions, + contentId, + topic: contentItem.title, + questionCount: questions.length, + }); + } catch (error) { + console.error('Exercise GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +function extractGradeLevel(content: { slug?: string; title?: string }): string { + const slug = content.slug || ''; + const title = content.title || ''; + + // Try to extract from slug (e.g., "klasse-5-mathematik") + const slugMatch = slug.match(/klasse[_-]?(\d+)/i); + if (slugMatch) return slugMatch[1]; + + // Try to extract from title (e.g., "Mathematik Klasse 5") + const titleMatch = title.match(/klasse\s*(\d+)/i); + if (titleMatch) return titleMatch[1]; + + return '5'; +} diff --git a/src/components/communication-view.tsx b/src/components/communication-view.tsx index 3a0d969..e816952 100644 --- a/src/components/communication-view.tsx +++ b/src/components/communication-view.tsx @@ -24,6 +24,14 @@ import { AlertTriangle, Volume2, Notebook, + Upload, + Play, + Pause, + Trash2, + Save, + File, + Image, + FileIcon, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -76,6 +84,20 @@ interface Message { sender?: { id: string; firstName: string; lastName: string; role: string }; } +interface NotebookItem { + id: string; + title: string; + color: string; + notebookType: string; + pages?: Array<{ id: string; title: string | null; pageNumber: number }>; +} + +interface TeacherNoteItem { + id: string; + content: string; + createdAt: string; +} + export default function CommunicationView() { const currentUser = useAppStore((s) => s.currentUser); const [rooms, setRooms] = useState([]); @@ -96,7 +118,6 @@ export default function CommunicationView() { useEffect(() => { fetchRooms(); - // Polling every 5 seconds for new messages const interval = setInterval(fetchRooms, 5000); return () => clearInterval(interval); }, [fetchRooms]); @@ -328,7 +349,6 @@ function RoomListItem({
- {/* Online/offline status indicator */}
@@ -343,7 +363,6 @@ function RoomListItem({
- {/* Unread count badge */} {room.messages && room.messages.length > 0 && room.status === 'active' && ( {room.messages.length} @@ -379,10 +398,23 @@ function ChatArea({ const [isSending, setIsSending] = useState(false); const [room, setRoom] = useState(null); const [isRecording, setIsRecording] = useState(false); - const [teacherNotes, setTeacherNotes] = useState(''); + const [recordingDuration, setRecordingDuration] = useState(0); const [showTeacherNotes, setShowTeacherNotes] = useState(false); + const [teacherNotes, setTeacherNotes] = useState([]); + const [newTeacherNote, setNewTeacherNote] = useState(''); + const [showShareNotesDialog, setShowShareNotesDialog] = useState(false); + const [notebooks, setNotebooks] = useState([]); + const [isSharingNote, setIsSharingNote] = useState(false); + const [playingVoiceId, setPlayingVoiceId] = useState(null); + const [dragOver, setDragOver] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const messagesEndRef = useRef(null); const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const recordingTimerRef = useRef | null>(null); + const audioRef = useRef(null); + const fileInputRef = useRef(null); const fetchMessages = useCallback(async () => { try { @@ -404,24 +436,55 @@ function ChatArea({ } }, [roomId]); + const fetchTeacherNotes = useCallback(async () => { + if (role !== 'TEACHER') return; + try { + const data = await apiGet<{ sharedNotes: Array<{ id: string; content: string; createdAt: string; metadata: string | null }> }>(`/api/communication-rooms/${roomId}/share-notes`); + // Convert shared notes to teacher note items for display + const notes = data.sharedNotes.map((n) => { + let meta: Record = {}; + try { meta = n.metadata ? JSON.parse(n.metadata) : {}; } catch { /* ignore */ } + return { + id: n.id, + content: `[${t('communication.note_shared')}: ${meta.notebookTitle || n.content}]`, + createdAt: n.createdAt, + }; + }); + setTeacherNotes(notes); + } catch { + // ignore + } + }, [roomId, role]); + + const fetchNotebooks = useCallback(async () => { + try { + const data = await apiGet }>>('/api/notebooks'); + setNotebooks(data); + } catch { + // ignore + } + }, []); + useEffect(() => { fetchMessages(); fetchRoom(); + fetchTeacherNotes(); const interval = setInterval(fetchMessages, 5000); return () => clearInterval(interval); - }, [fetchMessages, fetchRoom]); + }, [fetchMessages, fetchRoom, fetchTeacherNotes]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - const sendMessage = async () => { - if (!newMessage.trim()) return; + const sendMessage = async (content: string, messageType: string = 'text', metadata?: string) => { + if (!content && messageType === 'text') return; setIsSending(true); try { await apiPost(`/api/communication-rooms/${roomId}/messages`, { - content: newMessage, - messageType: 'text', + content, + messageType, + metadata: metadata || null, }); setNewMessage(''); fetchMessages(); @@ -465,38 +528,47 @@ function ChatArea({ } }; + // Voice recording with Web Audio API const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); - const audioChunks: BlobPart[] = []; + audioChunksRef.current = []; mediaRecorder.ondataavailable = (event) => { - audioChunks.push(event.data); + audioChunksRef.current.push(event.data); }; mediaRecorder.onstop = async () => { - const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); - // In a real app, you'd upload this to a server - // For now, just send a text message indicating voice note - try { - await apiPost(`/api/communication-rooms/${roomId}/messages`, { - content: t('communication.voice_message'), - messageType: 'voice', + const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); + const reader = new FileReader(); + reader.onloadend = async () => { + const base64Audio = reader.result as string; + const metadata = JSON.stringify({ + audioData: base64Audio, + duration: recordingDuration, + mimeType: 'audio/webm', }); - fetchMessages(); - onRefresh(); - } catch { - toast.error(t('communication.error_send')); - } + await sendMessage( + t('communication.voice_message'), + 'voice', + metadata, + ); + }; + reader.readAsDataURL(audioBlob); stream.getTracks().forEach((track) => track.stop()); }; mediaRecorderRef.current = mediaRecorder; mediaRecorder.start(); setIsRecording(true); + setRecordingDuration(0); + + recordingTimerRef.current = setInterval(() => { + setRecordingDuration((prev) => prev + 1); + }, 1000); } catch { - toast.error('Microphone access denied'); + toast.error(t('communication.voice_recording_error')); } }; @@ -504,6 +576,124 @@ function ChatArea({ if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = null; + } + } + }; + + // Voice playback + const playVoiceMessage = (msg: Message) => { + if (playingVoiceId === msg.id) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setPlayingVoiceId(null); + return; + } + + try { + let meta: Record = {}; + try { meta = msg.metadata ? JSON.parse(msg.metadata) : {}; } catch { /* ignore */ } + + const audioData = meta.audioData as string; + if (audioData) { + if (audioRef.current) { + audioRef.current.pause(); + } + const audio = new Audio(audioData); + audio.onended = () => setPlayingVoiceId(null); + audio.play(); + audioRef.current = audio; + setPlayingVoiceId(msg.id); + } + } catch { + // If audio playback fails, just show the message + } + }; + + // File sharing + const handleFileUpload = async (file: File) => { + if (file.size > 10 * 1024 * 1024) { + toast.error(t('communication.file_size_limit')); + return; + } + + setIsUploading(true); + try { + const reader = new FileReader(); + reader.onloadend = async () => { + const base64Data = reader.result as string; + const metadata = JSON.stringify({ + fileName: file.name, + fileSize: file.size, + fileType: file.type, + fileData: base64Data, + }); + await sendMessage(file.name, 'file', metadata); + }; + reader.readAsDataURL(file); + } catch { + toast.error('Error uploading file'); + } finally { + setIsUploading(false); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = () => { + setDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFileUpload(files[0]); + } + }; + + // Note sharing + const handleShareNote = async (notebookId: string) => { + setIsSharingNote(true); + try { + await apiPost(`/api/communication-rooms/${roomId}/share-notes`, { notebookId }); + toast.success(t('communication.note_shared')); + setShowShareNotesDialog(false); + fetchMessages(); + onRefresh(); + } catch { + toast.error('Error sharing notebook'); + } finally { + setIsSharingNote(false); + } + }; + + // Save teacher note + const handleSaveTeacherNote = async () => { + if (!newTeacherNote.trim()) return; + try { + await apiPost('/api/teacher-notes', { + studentId: room?.studentId, + category: 'GENERAL', + content: newTeacherNote.trim(), + isPrivate: true, + }); + toast.success(t('communication.notes_saved')); + setTeacherNotes((prev) => [ + { id: Date.now().toString(), content: newTeacherNote.trim(), createdAt: new Date().toISOString() }, + ...prev, + ]); + setNewTeacherNote(''); + } catch { + toast.error('Error saving note'); } }; @@ -525,6 +715,18 @@ function ChatArea({ return `${date.toLocaleDateString()} ${time}`; }; + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const getFileIcon = (fileType: string) => { + if (fileType.startsWith('image/')) return ; + if (fileType === 'application/pdf') return ; + return ; + }; + return ( {/* Chat Header */} @@ -539,7 +741,6 @@ function ChatArea({
- {/* Online status */}
@@ -607,14 +808,59 @@ function ChatArea({ exit={{ opacity: 0, height: 0 }} className="border-b bg-amber-50 dark:bg-amber-950/20 p-4" > - -