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
This commit is contained in:
Mustafa Cetin committed 2026-07-29 21:01:32 +00:00
1 parent 2f266a2143
commit 44ff170fca
12 files changed
+3655 -476

No files matched your search

+3 -1
View File
@@ -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 } : {}),
},
});
@@ -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<string, unknown> = {};
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 });
}
}
@@ -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<NextResponse> {
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<string, unknown> = {
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<string, { name: string; scores: number[] }> = {};
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<NextResponse> {
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');
@@ -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';
}
+472 -126
View File
@@ -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<CommunicationRoom[]>([]);
@@ -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({
<div className="h-8 w-8 rounded-full bg-emerald-100 dark:bg-emerald-950/50 flex items-center justify-center">
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
{/* Online/offline status indicator */}
<div className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-gray-900 ${
room.status === 'active' ? 'bg-emerald-500' : room.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
@@ -343,7 +363,6 @@ function RoomListItem({
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Unread count badge */}
{room.messages && room.messages.length > 0 && room.status === 'active' && (
<span className="flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-emerald-500 text-white text-[10px] font-bold">
{room.messages.length}
@@ -379,10 +398,23 @@ function ChatArea({
const [isSending, setIsSending] = useState(false);
const [room, setRoom] = useState<CommunicationRoom | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [teacherNotes, setTeacherNotes] = useState('');
const [recordingDuration, setRecordingDuration] = useState(0);
const [showTeacherNotes, setShowTeacherNotes] = useState(false);
const [teacherNotes, setTeacherNotes] = useState<TeacherNoteItem[]>([]);
const [newTeacherNote, setNewTeacherNote] = useState('');
const [showShareNotesDialog, setShowShareNotesDialog] = useState(false);
const [notebooks, setNotebooks] = useState<NotebookItem[]>([]);
const [isSharingNote, setIsSharingNote] = useState(false);
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<BlobPart[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<string, unknown> = {};
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<Array<{ id: string; title: string; color: string; notebookType: string; pages?: Array<{ id: string; title: string | null; pageNumber: number }> }>>('/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<string, unknown> = {};
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 <Image className="h-4 w-4" alt="" />;
if (fileType === 'application/pdf') return <FileText className="h-4 w-4" />;
return <FileIcon className="h-4 w-4" />;
};
return (
<Card className={`flex-1 flex flex-col min-h-0 ${isMobile ? 'rounded-none border-0' : ''}`}>
{/* Chat Header */}
@@ -539,7 +741,6 @@ function ChatArea({
<div className="h-10 w-10 rounded-full bg-emerald-100 dark:bg-emerald-950/50 flex items-center justify-center">
<User className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
{/* Online status */}
<div className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white dark:border-gray-900 ${
room?.status === 'active' ? 'bg-emerald-500' : room?.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
@@ -607,14 +808,59 @@ function ChatArea({
exit={{ opacity: 0, height: 0 }}
className="border-b bg-amber-50 dark:bg-amber-950/20 p-4"
>
<Label className="text-sm font-medium">{t('communication.teacher_notes')}</Label>
<Textarea
value={teacherNotes}
onChange={(e) => setTeacherNotes(e.target.value)}
placeholder={t('communication.teacher_notes_placeholder')}
rows={3}
className="mt-1"
/>
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium flex items-center gap-2">
<StickyNote className="h-4 w-4 text-amber-500" />
{t('communication.teacher_notes_panel')}
</Label>
</div>
<div className="flex gap-2 mb-2">
<Textarea
value={newTeacherNote}
onChange={(e) => setNewTeacherNote(e.target.value)}
placeholder={t('communication.teacher_notes_placeholder')}
rows={2}
className="flex-1"
/>
<Button
onClick={handleSaveTeacherNote}
disabled={!newTeacherNote.trim()}
className="bg-emerald-500 hover:bg-emerald-600 text-white self-end"
size="sm"
>
<Save className="h-4 w-4" />
</Button>
</div>
{teacherNotes.length > 0 && (
<div className="space-y-1 max-h-32 overflow-y-auto">
{teacherNotes.map((note) => (
<div key={note.id} className="flex items-start gap-2 p-2 rounded bg-white/50 dark:bg-gray-800/50 text-xs">
<FileText className="h-3 w-3 mt-0.5 text-amber-500 shrink-0" />
<div className="min-w-0">
<p className="text-muted-foreground">{note.content}</p>
<p className="text-muted-foreground mt-0.5">{formatTime(note.createdAt)}</p>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* Drag and drop overlay */}
<AnimatePresence>
{dragOver && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-10 bg-emerald-50/80 dark:bg-emerald-950/50 flex items-center justify-center"
>
<div className="text-center">
<Upload className="h-12 w-12 text-emerald-500 mx-auto mb-2" />
<p className="text-lg font-medium text-emerald-700 dark:text-emerald-300">{t('communication.drop_zone')}</p>
</div>
</motion.div>
)}
</AnimatePresence>
@@ -641,7 +887,12 @@ function ChatArea({
</AnimatePresence>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div
className="flex-1 overflow-y-auto p-4 space-y-3"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isLoadingMessages ? (
<div className="flex items-center justify-center h-full">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
@@ -657,6 +908,9 @@ function ChatArea({
const isFile = msg.messageType === 'file';
const isNoteShare = msg.messageType === 'note_share';
let msgMeta: Record<string, unknown> = {};
try { msgMeta = msg.metadata ? JSON.parse(msg.metadata) : {}; } catch { /* ignore */ }
return (
<motion.div
key={msg.id}
@@ -679,29 +933,64 @@ function ChatArea({
}`}
>
{isVoice && (
<div className="flex items-center gap-2">
<Volume2 className="h-4 w-4" />
<span>{t('communication.voice_message')}</span>
<div className="flex items-center gap-2 min-w-[160px]">
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 shrink-0 ${isOwn ? 'text-white hover:bg-white/20' : 'text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/30'}`}
onClick={() => playVoiceMessage(msg)}
>
{playingVoiceId === msg.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1">
<div className="flex items-center gap-1">
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
className={`w-1 rounded-full ${isOwn ? 'bg-white/60' : 'bg-emerald-300 dark:bg-emerald-700'}`}
style={{ height: `${Math.max(4, Math.random() * 16)}px` }}
/>
))}
</div>
<p className={`text-xs mt-0.5 ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
{msgMeta.duration ? formatDuration(msgMeta.duration as number) : '0:00'}
</p>
</div>
<Volume2 className={`h-4 w-4 ${isOwn ? 'text-white/60' : 'text-emerald-500'}`} />
</div>
)}
{isFile && (
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
<span>{t('communication.file_shared')}</span>
<div className="flex items-center gap-2 min-w-[200px]">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg shrink-0 ${isOwn ? 'bg-white/20' : 'bg-emerald-50 dark:bg-emerald-900/30'}`}>
{getFileIcon(msgMeta.fileType as string || 'application/octet-stream')}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{msgMeta.fileName as string || msg.content}</p>
<p className={`text-xs ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
{msgMeta.fileSize ? `${(Number(msgMeta.fileSize) / 1024).toFixed(1)} KB` : ''}
</p>
</div>
</div>
)}
{isNoteShare && (
<div className="flex items-center gap-2">
<Notebook className="h-4 w-4" />
<span>{t('communication.note_shared')}</span>
<div className="flex items-center gap-2 min-w-[180px]">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg shrink-0 ${isOwn ? 'bg-white/20' : 'bg-emerald-50 dark:bg-emerald-900/30'}`}>
<Notebook className={`h-5 w-5 ${isOwn ? 'text-white' : 'text-emerald-600 dark:text-emerald-400'}`} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">{t('communication.note_shared')}</p>
<p className={`text-xs ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
{msgMeta.notebookTitle as string || msg.content}
</p>
{msgMeta.pageCount && (
<p className={`text-xs ${isOwn ? 'text-white/60' : 'text-muted-foreground'}`}>
{msgMeta.pageCount as number} {t('communication.notebook_pages')}
</p>
)}
</div>
</div>
)}
{!isVoice && !isFile && !isNoteShare && <p className="text-sm">{msg.content}</p>}
{msg.fileUrl && (
<a href={msg.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs underline mt-1 block opacity-80">
{t('communication.file_shared')}
</a>
)}
</div>
<p className={`text-xs text-muted-foreground mt-1 ${isOwn ? 'text-right mr-1' : 'ml-1'}`}>
{formatTime(msg.createdAt)}
@@ -717,93 +1006,99 @@ function ChatArea({
{/* Message Input */}
{!isAdmin && room?.status === 'active' && (
<div className="p-4 border-t">
<div className="flex items-center gap-2">
{role === 'TEACHER' && (
<>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
apiPost(`/api/communication-rooms/${roomId}/messages`, {
content: t('communication.note_shared'),
messageType: 'note_share',
}).then(() => {
fetchMessages();
onRefresh();
});
}}
>
<StickyNote className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
>
<Paperclip className="h-4 w-4" />
</Button>
</>
)}
{role === 'STUDENT' && (
{isRecording ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 flex-1">
<div className="h-3 w-3 rounded-full bg-rose-500 animate-pulse" />
<span className="text-sm text-rose-500 font-medium">{t('communication.recording')}</span>
<span className="text-sm text-muted-foreground">{formatDuration(recordingDuration)}</span>
</div>
<Button
variant="outline"
size="sm"
className="text-rose-600 border-rose-200 hover:bg-rose-50"
onClick={stopRecording}
>
<MicOff className="h-4 w-4 mr-1" />
{t('communication.stop_recording')}
</Button>
</div>
) : (
<div className="flex items-center gap-2">
{/* Share Notes Button */}
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
apiPost(`/api/communication-rooms/${roomId}/messages`, {
content: t('communication.note_shared'),
messageType: 'note_share',
}).then(() => {
fetchMessages();
onRefresh();
});
fetchNotebooks();
setShowShareNotesDialog(true);
}}
title={t('communication.share_notes')}
>
<StickyNote className="h-4 w-4" />
<Notebook className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={isRecording ? stopRecording : startRecording}
>
{isRecording ? (
<MicOff className="h-4 w-4 text-rose-500" />
) : (
{/* File Upload Button */}
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => fileInputRef.current?.click()}
title={t('communication.attach_file')}
disabled={isUploading}
>
{isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.txt,.xls,.xlsx"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
e.target.value = '';
}}
/>
{/* Voice Record Button */}
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={startRecording}
title={t('communication.record_voice')}
>
<Mic className="h-4 w-4" />
)}
</Button>
{isRecording && (
<div className="flex items-center gap-2 text-sm text-rose-500">
<div className="h-2 w-2 rounded-full bg-rose-500 animate-pulse" />
{t('communication.recording')}
</div>
)}
{!isRecording && (
<>
<Input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder={t('communication.type_message')}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
/>
<Button
onClick={sendMessage}
disabled={isSending || !newMessage.trim()}
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
>
<Send className="h-4 w-4" />
</Button>
</>
)}
</Button>
{/* Text Input */}
<Input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder={t('communication.type_message')}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(newMessage);
}
}}
/>
<Button
onClick={() => sendMessage(newMessage)}
disabled={isSending || !newMessage.trim()}
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
>
<Send className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<Paperclip className="h-3 w-3" />
<span>{t('communication.supported_types')}</span>
</div>
</div>
)}
@@ -827,6 +1122,57 @@ function ChatArea({
</p>
</div>
)}
{/* Share Notes Dialog */}
<Dialog open={showShareNotesDialog} onOpenChange={setShowShareNotesDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Notebook className="h-5 w-5 text-emerald-500" />
{t('communication.share_notes')}
</DialogTitle>
<DialogDescription>
{t('communication.select_notebook')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 max-h-96 overflow-y-auto">
{notebooks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t('communication.no_notebooks')}</p>
) : (
notebooks.map((nb) => (
<motion.button
key={nb.id}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="w-full text-left p-3 rounded-lg border hover:border-emerald-300 dark:hover:border-emerald-700 transition-colors"
onClick={() => handleShareNote(nb.id)}
disabled={isSharingNote}
>
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-lg shrink-0"
style={{ backgroundColor: nb.color || '#10b981' }}
>
<Notebook className="h-5 w-5 text-white" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{nb.title}</p>
<p className="text-xs text-muted-foreground">
{nb.pages?.length || 0} {t('communication.notebook_pages')}
</p>
</div>
</div>
</motion.button>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowShareNotesDialog(false)}>
{t('action.cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
+600
View File
@@ -64,6 +64,12 @@ import {
Notebook,
History,
Loader2,
Building2,
Calculator,
Microscope,
Languages,
Brain,
CalendarDays,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -111,10 +117,13 @@ import {
createCompetitionReward,
fetchRewardClaims,
claimReward,
fetchFederationCompetitions,
createFederationCompetition,
type CompetitionData,
type CompetitionLeaderboardEntry,
type CompetitionRewardData,
type RewardClaimData,
type FederationLeaderboardEntry,
} from '@/lib/api';
import { toast } from 'sonner';
@@ -770,6 +779,27 @@ export default function CompetitionsView() {
// Register
const [isRegistering, setIsRegistering] = useState(false);
// Federation state
const [federationCompetitions, setFederationCompetitions] = useState<CompetitionData[]>([]);
const [federationLeaderboard, setFederationLeaderboard] = useState<FederationLeaderboardEntry[]>([]);
const [federationLoading, setFederationLoading] = useState(true);
const [federationCategory, setFederationCategory] = useState<string>('all');
const [federationSchedule, setFederationSchedule] = useState<string>('all');
const [federationCreateOpen, setFederationCreateOpen] = useState(false);
const [isCreatingFederation, setIsCreatingFederation] = useState(false);
const [federationForm, setFederationForm] = useState({
title: '',
description: '',
category: 'math_olympiad',
schedule: 'monthly',
startDate: '',
endDate: '',
registrationDeadline: '',
maxParticipants: '',
rules: '',
isPublic: true,
});
const isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const isTeacher = currentUser?.role === 'TEACHER';
const isStudent = currentUser?.role === 'STUDENT';
@@ -800,6 +830,95 @@ export default function CompetitionsView() {
loadCompetitions();
}, [loadCompetitions]);
/* ── Fetch federation competitions ──────────────────────────────── */
const loadFederation = useCallback(async () => {
setFederationLoading(true);
try {
const result = await fetchFederationCompetitions(
federationCategory !== 'all' ? federationCategory : undefined,
federationSchedule !== 'all' ? federationSchedule : undefined,
undefined,
100,
);
setFederationCompetitions(result.competitions);
setFederationLeaderboard(result.schoolLeaderboard);
} catch (err) {
console.error('Failed to load federation competitions:', err);
} finally {
setFederationLoading(false);
}
}, [federationCategory, federationSchedule]);
useEffect(() => {
loadFederation();
}, [loadFederation]);
/* ── Create federation competition ─────────────────────────────── */
const handleCreateFederation = useCallback(async () => {
if (!schoolId) return;
if (!federationForm.title.trim()) {
toast.error('Title is required');
return;
}
if (!federationForm.startDate || !federationForm.endDate) {
toast.error('Start and end dates are required');
return;
}
setIsCreatingFederation(true);
try {
await createFederationCompetition({
schoolId,
title: federationForm.title.trim(),
description: federationForm.description.trim() || null,
category: federationForm.category,
schedule: federationForm.schedule,
startDate: new Date(federationForm.startDate).toISOString(),
endDate: new Date(federationForm.endDate).toISOString(),
registrationDeadline: federationForm.registrationDeadline ? new Date(federationForm.registrationDeadline).toISOString() : null,
maxParticipants: federationForm.maxParticipants ? parseInt(federationForm.maxParticipants, 10) : null,
rules: federationForm.rules.trim() || null,
isPublic: federationForm.isPublic,
});
toast.success(t('federation.create_success'));
setFederationCreateOpen(false);
setFederationForm({
title: '',
description: '',
category: 'math_olympiad',
schedule: 'monthly',
startDate: '',
endDate: '',
registrationDeadline: '',
maxParticipants: '',
rules: '',
isPublic: true,
});
loadFederation();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('federation.error_create');
toast.error(message);
} finally {
setIsCreatingFederation(false);
}
}, [schoolId, federationForm, loadFederation]);
/* ── Join federation competition ────────────────────────────────── */
const handleJoinFederation = useCallback(async (comp: CompetitionData) => {
if (!currentUser || !schoolId) return;
try {
await registerCompetitionParticipant(comp.id, {
participantType: 'user',
participantId: currentUser.id,
userId: currentUser.id,
});
toast.success(t('federation.register_success'));
loadFederation();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('federation.error_join');
toast.error(message);
}
}, [currentUser, schoolId, loadFederation]);
/* ── Fetch my claims ─────────────────────────────────────────── */
const loadMyClaims = useCallback(async () => {
try {
@@ -1228,6 +1347,10 @@ export default function CompetitionsView() {
<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>
<TabsTrigger value="federation" className="min-h-[44px]">
<Building2 className="h-4 w-4 mr-1" />
{t('federation.title')}
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
@@ -1801,9 +1924,209 @@ export default function CompetitionsView() {
</Card>
</div>
</TabsContent>
{/* Federation Tab */}
<TabsContent value="federation" className="space-y-6 mt-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
<Building2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h2 className="text-lg font-semibold">{t('federation.title')}</h2>
<p className="text-sm text-muted-foreground">{t('federation.description')}</p>
</div>
</div>
{canCreate && (
<Button
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
onClick={() => setFederationCreateOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
{t('federation.create')}
</Button>
)}
</div>
{/* Federation Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<Select value={federationCategory} onValueChange={setFederationCategory}>
<SelectTrigger className="w-full sm:w-[200px] min-h-[44px]">
<SelectValue placeholder={t('federation.category_label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('federation.category_label')}</SelectItem>
<SelectItem value="math_olympiad">{t('federation.math_olympiad')}</SelectItem>
<SelectItem value="science_bowl">{t('federation.science_bowl')}</SelectItem>
<SelectItem value="language_quiz">{t('federation.language_quiz')}</SelectItem>
<SelectItem value="general_knowledge">{t('federation.general_knowledge')}</SelectItem>
</SelectContent>
</Select>
<Select value={federationSchedule} onValueChange={setFederationSchedule}>
<SelectTrigger className="w-full sm:w-[160px] min-h-[44px]">
<SelectValue placeholder={t('federation.schedule')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('federation.schedule')}</SelectItem>
<SelectItem value="weekly">{t('federation.weekly')}</SelectItem>
<SelectItem value="monthly">{t('federation.monthly')}</SelectItem>
<SelectItem value="quarterly">{t('federation.quarterly')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* School Leaderboard */}
{federationLeaderboard.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Crown className="h-5 w-5 text-amber-500" />
{t('federation.school_ranking')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-96 overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-background">
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="pb-2 pl-3 w-16">{t('federation.school_rank')}</th>
<th className="pb-2">{t('federation.district_schools')}</th>
<th className="pb-2 text-center">{t('federation.participants')}</th>
<th className="pb-2 pr-3 text-right">{t('federation.team_score')}</th>
</tr>
</thead>
<tbody>
{federationLeaderboard.map((entry, idx) => (
<motion.tr
key={entry.schoolId}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.03 }}
className={`border-b last:border-0 hover:bg-muted/50 transition-colors ${
entry.schoolId === schoolId ? 'bg-emerald-50/50 dark:bg-emerald-900/10' : ''
}`}
>
<td className="py-2.5 pl-3">
<div className="flex items-center gap-1.5">
{entry.rank === 1 ? (
<Crown className="h-5 w-5 text-amber-500" />
) : entry.rank === 2 ? (
<Medal className="h-5 w-5 text-gray-400" />
) : entry.rank === 3 ? (
<Medal className="h-5 w-5 text-amber-700" />
) : (
<span className="text-sm font-medium text-muted-foreground w-5 text-center">{entry.rank}</span>
)}
</div>
</td>
<td className="py-2.5">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded bg-emerald-100 dark:bg-emerald-900/30 shrink-0">
<Building2 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
<span className="text-sm font-medium">{entry.schoolName}</span>
{entry.schoolId === schoolId && (
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
{t('federation.your_school')}
</Badge>
)}
</div>
</td>
<td className="py-2.5 text-center">
<span className="text-sm text-muted-foreground">{entry.participantCount}</span>
</td>
<td className="py-2.5 pr-3 text-right">
<span className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">{entry.teamScore}</span>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Federation Competitions List */}
{federationLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}><CardContent className="p-4"><Skeleton className="h-32" /></CardContent></Card>
))}
</div>
) : federationCompetitions.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-16 text-muted-foreground"
>
<Building2 className="h-16 w-16 mb-4 opacity-20" />
<p className="text-lg font-medium">{t('federation.no_competitions')}</p>
<p className="text-sm mt-1">{t('federation.no_district')}</p>
</motion.div>
) : (
<>
{/* Active Federation Competitions */}
{federationCompetitions.filter((c) => c.status === 'active' || c.status === 'registration').length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<Flame className="h-5 w-5 text-emerald-500" />
<h3 className="text-lg font-semibold">{t('federation.active_federation')}</h3>
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300">
{federationCompetitions.filter((c) => c.status === 'active' || c.status === 'registration').length}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{federationCompetitions
.filter((c) => c.status === 'active' || c.status === 'registration')
.map((comp) => (
<FederationCompetitionCard
key={comp.id}
competition={comp}
onView={handleViewCompetition}
onJoin={handleJoinFederation}
isStudent={isStudent}
currentSchoolId={schoolId}
/>
))}
</AnimatePresence>
</div>
</section>
)}
{/* Completed Federation Competitions */}
{federationCompetitions.filter((c) => c.status === 'completed').length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<CheckCircle2 className="h-5 w-5 text-amber-500" />
<h3 className="text-lg font-semibold">{t('federation.completed')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<AnimatePresence>
{federationCompetitions
.filter((c) => c.status === 'completed')
.map((comp) => (
<FederationCompetitionCard
key={comp.id}
competition={comp}
onView={handleViewCompetition}
onJoin={handleJoinFederation}
isStudent={isStudent}
currentSchoolId={schoolId}
/>
))}
</AnimatePresence>
</div>
</section>
)}
</>
)}
</TabsContent>
</Tabs>
{/* ── Competition Detail Dialog ──────────────────────────── */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
{selectedCompetition && (
@@ -2458,10 +2781,287 @@ export default function CompetitionsView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Federation Create Dialog ───────────────────────────────── */}
<Dialog open={federationCreateOpen} onOpenChange={setFederationCreateOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-emerald-500" />
{t('federation.create')}
</DialogTitle>
<DialogDescription>
{t('federation.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<Label>{t('competition.name')} *</Label>
<Input
value={federationForm.title}
onChange={(e) => setFederationForm((f) => ({ ...f, title: e.target.value }))}
placeholder={t('federation.create') + '...'}
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.description')}</Label>
<Textarea
value={federationForm.description}
onChange={(e) => setFederationForm((f) => ({ ...f, description: e.target.value }))}
placeholder={t('federation.description')}
className="mt-1"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('federation.category_label')} *</Label>
<Select
value={federationForm.category}
onValueChange={(v) => setFederationForm((f) => ({ ...f, category: v }))}
>
<SelectTrigger className="mt-1 min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="math_olympiad">
<span className="flex items-center gap-2"><Calculator className="h-4 w-4" /> {t('federation.math_olympiad')}</span>
</SelectItem>
<SelectItem value="science_bowl">
<span className="flex items-center gap-2"><Microscope className="h-4 w-4" /> {t('federation.science_bowl')}</span>
</SelectItem>
<SelectItem value="language_quiz">
<span className="flex items-center gap-2"><Languages className="h-4 w-4" /> {t('federation.language_quiz')}</span>
</SelectItem>
<SelectItem value="general_knowledge">
<span className="flex items-center gap-2"><Brain className="h-4 w-4" /> {t('federation.general_knowledge')}</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('federation.schedule')} *</Label>
<Select
value={federationForm.schedule}
onValueChange={(v) => setFederationForm((f) => ({ ...f, schedule: v }))}
>
<SelectTrigger className="mt-1 min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weekly">
<span className="flex items-center gap-2"><CalendarDays className="h-4 w-4" /> {t('federation.weekly')}</span>
</SelectItem>
<SelectItem value="monthly">
<span className="flex items-center gap-2"><Calendar className="h-4 w-4" /> {t('federation.monthly')}</span>
</SelectItem>
<SelectItem value="quarterly">
<span className="flex items-center gap-2"><Calendar className="h-4 w-4" /> {t('federation.quarterly')}</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('competition.start_date')} *</Label>
<Input
type="datetime-local"
value={federationForm.startDate}
onChange={(e) => setFederationForm((f) => ({ ...f, startDate: e.target.value }))}
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.end_date')} *</Label>
<Input
type="datetime-local"
value={federationForm.endDate}
onChange={(e) => setFederationForm((f) => ({ ...f, endDate: e.target.value }))}
className="mt-1 min-h-[44px]"
/>
</div>
</div>
<div>
<Label>{t('competition.max_participants')}</Label>
<Input
type="number"
value={federationForm.maxParticipants}
onChange={(e) => setFederationForm((f) => ({ ...f, maxParticipants: e.target.value }))}
placeholder={t('competition.unlimited')}
className="mt-1 min-h-[44px]"
/>
</div>
<div>
<Label>{t('competition.rules')}</Label>
<Textarea
value={federationForm.rules}
onChange={(e) => setFederationForm((f) => ({ ...f, rules: e.target.value }))}
placeholder={t('competition.rules') + '...'}
className="mt-1"
rows={3}
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" className="min-h-[44px]" onClick={() => setFederationCreateOpen(false)}>
{t('action.cancel')}
</Button>
<Button
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
onClick={handleCreateFederation}
disabled={isCreatingFederation}
>
{isCreatingFederation ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{t('federation.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
/* ── Federation Competition Card ──────────────────────────────────── */
function FederationCompetitionCard({
competition,
onView,
onJoin,
isStudent,
currentSchoolId,
}: {
competition: CompetitionData;
onView: (c: CompetitionData) => void;
onJoin: (c: CompetitionData) => void;
isStudent: boolean;
currentSchoolId: string | null;
}) {
const progress = getTimeProgress(competition.startDate, competition.endDate);
const isRegistration = competition.status === 'registration';
const isActive = competition.status === 'active';
const participantCount = competition._count?.participants ?? 0;
const getFederationCategoryIcon = (category: string) => {
switch (category) {
case 'math_olympiad': return <Calculator className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
case 'science_bowl': return <Microscope className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
case 'language_quiz': return <Languages className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
case 'general_knowledge': return <Brain className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
default: return <Trophy className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />;
}
};
const getFederationCategoryLabel = (category: string) => {
switch (category) {
case 'math_olympiad': return t('federation.math_olympiad');
case 'science_bowl': return t('federation.science_bowl');
case 'language_quiz': return t('federation.language_quiz');
case 'general_knowledge': return t('federation.general_knowledge');
default: return category;
}
};
const getScheduleLabel = (schedule: string | null) => {
switch (schedule) {
case 'weekly': return t('federation.weekly');
case 'monthly': return t('federation.monthly');
case 'quarterly': return t('federation.quarterly');
default: return schedule || '';
}
};
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2 }}
whileHover={{ y: -2 }}
className="h-full"
>
<Card className="border-l-4 border-l-emerald-500 h-full flex flex-col cursor-pointer transition-shadow hover:shadow-md" onClick={() => onView(competition)}>
<CardHeader className="pb-3">
<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 bg-emerald-50 dark:bg-emerald-900/30 shrink-0">
{getFederationCategoryIcon(competition.category)}
</div>
<div className="min-w-0">
<CardTitle className="text-base leading-tight truncate">{competition.title}</CardTitle>
<CardDescription className="text-xs mt-0.5 flex items-center gap-1">
<Building2 className="h-3 w-3" />
{getFederationCategoryLabel(competition.category)}
</CardDescription>
</div>
</div>
<Badge className={`${getStatusColor(competition.status)} text-xs shrink-0`}>
{getStatusLabel(competition.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="flex-1 pb-3 space-y-3">
{competition.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{competition.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{participantCount}
</span>
<span className="flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5" />
{getScheduleLabel(competition.federationSchedule)}
</span>
<span className="flex items-center gap-1">
<Building2 className="h-3.5 w-3.5" />
{competition.school?.name}
</span>
</div>
{(isActive || isRegistration) && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{getTimeRemaining(competition.endDate)}
</span>
<span className="text-muted-foreground">{progress}%</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
)}
</CardContent>
<CardFooter className="pt-0 pb-4 gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 min-h-[44px]"
onClick={(e) => { e.stopPropagation(); onView(competition); }}
>
<Eye className="h-4 w-4 mr-1" />
{t('competition.details')}
</Button>
{isRegistration && isStudent && (
<Button
size="sm"
className="flex-1 min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
onClick={(e) => { e.stopPropagation(); onJoin(competition); }}
>
<UserPlus className="h-4 w-4 mr-1" />
{t('federation.join')}
</Button>
)}
</CardFooter>
</Card>
</motion.div>
);
}
/* ── CompetitionRewardsSection (for rewards tab) ────────────────── */
function CompetitionRewardsSection({
+413 -79
View File
@@ -9,6 +9,7 @@ import {
Palette, Dumbbell, Map, Settings, FileText, MessageSquare, Shield,
Loader2, Sparkles, RefreshCw, Filter, ToggleLeft, ToggleRight, Send,
AlertTriangle, CheckCircle2, Clock, XCircle, ChevronDown, Info,
PartyPopper, Trophy, RotateCcw,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -30,6 +31,13 @@ import { toast } from 'sonner';
/* ── Types ─────────────────────────────────────────────────────────── */
interface PracticeQuestion {
question: string;
options: string[];
correctAnswer: number;
explanation: string;
}
interface SubjectCategory {
id: string;
schoolId: string;
@@ -198,10 +206,14 @@ export default function SubjectsView() {
// Practice state
const [isPracticing, setIsPracticing] = useState(false);
const [currentQuestion, setCurrentQuestion] = useState(0);
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
const [showResult, setShowResult] = useState(false);
const [practiceScore, setPracticeScore] = useState(0);
const [practiceComplete, setPracticeComplete] = useState(false);
const [practiceQuestions, setPracticeQuestions] = useState<PracticeQuestion[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [answerHistory, setAnswerHistory] = useState<Array<{ questionIndex: number; selectedAnswer: number; isCorrect: boolean }>>([]);
const [showConfetti, setShowConfetti] = useState(false);
// Dialog state
const [showCategoryDialog, setShowCategoryDialog] = useState(false);
@@ -343,6 +355,8 @@ export default function SubjectsView() {
const handleTopicClick = (topic: SubjectContentItem) => {
setSelectedTopic(topic);
setIsPracticing(false);
setPracticeQuestions([]);
setAnswerHistory([]);
};
const handleBack = () => {
@@ -365,34 +379,127 @@ export default function SubjectsView() {
}
};
const handleStartPractice = () => {
const handleStartPractice = async () => {
if (!selectedTopic) return;
setIsGenerating(true);
try {
// First try to load existing questions
const data = await apiGet<{ questions: PracticeQuestion[]; questionCount: number }>(
`/api/subject-contents/exercises?contentId=${selectedTopic.id}`
);
if (data.questions && data.questions.length > 0) {
setPracticeQuestions(data.questions);
setIsPracticing(true);
setCurrentQuestion(0);
setPracticeScore(0);
setSelectedAnswer(null);
setShowResult(false);
setPracticeComplete(false);
setAnswerHistory([]);
setShowConfetti(false);
} else {
// No existing questions, generate new ones
await handleGenerateQuestions();
}
} catch {
// Fallback: try to generate
await handleGenerateQuestions();
} finally {
setIsGenerating(false);
}
};
const handleGenerateQuestions = async () => {
if (!selectedTopic) return;
setIsGenerating(true);
try {
const data = await apiPost<{
questions: PracticeQuestion[];
requestsToday: number;
maxRequests: number;
}>('/api/subject-contents/exercises', {
contentId: selectedTopic.id,
topic: selectedTopic.title,
count: 5,
difficulty: selectedTopic.difficulty || 'medium',
});
if (data.questions && data.questions.length > 0) {
setPracticeQuestions(data.questions);
setIsPracticing(true);
setCurrentQuestion(0);
setPracticeScore(0);
setSelectedAnswer(null);
setShowResult(false);
setPracticeComplete(false);
setAnswerHistory([]);
setShowConfetti(false);
toast.success(t('practice.xp_earned', { xp: '10' }));
}
} catch (err: unknown) {
const error = err as { message?: string };
if (error.message?.includes('429') || error.message?.includes('limit')) {
toast.error(t('practice.rate_limit_reached'));
} else {
toast.error(t('practice.generation_error'));
}
} finally {
setIsGenerating(false);
}
};
const handleAnswer = (answerIndex: number) => {
if (showResult || !practiceQuestions[currentQuestion]) return;
setSelectedAnswer(answerIndex);
setShowResult(true);
const isCorrect = answerIndex === practiceQuestions[currentQuestion].correctAnswer;
if (isCorrect) {
setPracticeScore((s) => s + 1);
}
setAnswerHistory((prev) => [
...prev,
{ questionIndex: currentQuestion, selectedAnswer: answerIndex, isCorrect },
]);
};
const handleNextQuestion = () => {
if (currentQuestion + 1 >= practiceQuestions.length) {
setPracticeComplete(true);
const pct = Math.round(((practiceScore + (selectedAnswer === practiceQuestions[currentQuestion]?.correctAnswer ? 0 : 0)) / practiceQuestions.length) * 100);
if (pct >= 80) {
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 4000);
}
} else {
setCurrentQuestion((q) => q + 1);
setSelectedAnswer(null);
setShowResult(false);
}
};
const handleRetryIncorrect = () => {
const incorrectQuestions = answerHistory
.filter((h) => !h.isCorrect)
.map((h) => practiceQuestions[h.questionIndex]);
if (incorrectQuestions.length === 0) {
handleStartPractice();
return;
}
setPracticeQuestions(incorrectQuestions);
setIsPracticing(true);
setCurrentQuestion(0);
setPracticeScore(0);
setSelectedAnswer(null);
setShowResult(false);
setPracticeComplete(false);
};
const handleAnswer = (answer: string) => {
setSelectedAnswer(answer);
setShowResult(true);
// Simulate correct answer 50% of the time
const isCorrect = Math.random() > 0.5;
if (isCorrect) {
setPracticeScore((s) => s + 1);
}
};
const handleNextQuestion = () => {
const totalQ = selectedTopic?.questionCount || 5;
if (currentQuestion + 1 >= totalQ) {
setPracticeComplete(true);
} else {
setCurrentQuestion((q) => q + 1);
setSelectedAnswer(null);
setShowResult(false);
}
setAnswerHistory([]);
setShowConfetti(false);
};
const handleSeedData = async () => {
@@ -1086,7 +1193,36 @@ export default function SubjectsView() {
return renderPractice();
}
const totalQ = selectedTopic.questionCount || 5;
if (isGenerating) {
return (
<div className="max-w-md mx-auto space-y-6">
<Card className="border-0 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-8 text-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 2, ease: 'linear' }}
className="inline-block"
>
<Sparkles className="h-12 w-12 text-white" />
</motion.div>
<h2 className="text-xl font-bold text-white mt-4">
{t('practice.generating')}
</h2>
<p className="text-emerald-100 mt-2 text-sm">
{t('practice.generate_questions')}
</p>
</div>
<CardContent className="p-6 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
<span className="text-sm text-gray-500">{t('practice.generating')}</span>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Breadcrumb */}
@@ -1116,16 +1252,22 @@ export default function SubjectsView() {
<p className="text-emerald-100 mt-1">{selectedTopic.description}</p>
)}
<div className="flex items-center gap-2 mt-3">
<Badge className="text-xs bg-white/20 text-white border-0 hover:bg-white/30">
{totalQ} {t('subjects.question_count')}
</Badge>
<DifficultyBadge difficulty={selectedTopic.difficulty} />
{selectedTopic.questionCount > 0 && (
<Badge className="text-xs bg-white/20 text-white border-0 hover:bg-white/30">
{selectedTopic.questionCount} {t('subjects.question_count')}
</Badge>
)}
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button onClick={handleStartPractice} className="gap-1 bg-white text-emerald-700 hover:bg-emerald-50 shadow-md">
<Play className="h-4 w-4" />
{t('subjects.start_practice')}
{t('practice.start')}
</Button>
<Button onClick={handleGenerateQuestions} variant="outline" className="gap-1 bg-white/20 border-white/30 text-white hover:bg-white/30 hover:text-white">
<Sparkles className="h-4 w-4" />
{t('practice.generate_questions')}
</Button>
{isTeacher && (
<Button
@@ -1172,40 +1314,138 @@ export default function SubjectsView() {
);
};
/* ── Confetti Particle ───────────────────────────────────────────── */
function ConfettiParticle({ delay, color }: { delay: number; color: string }) {
return (
<motion.div
className="absolute w-3 h-3 rounded-sm"
style={{ backgroundColor: color, left: `${Math.random() * 100}%`, top: '-10px' }}
initial={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}
animate={{
opacity: [1, 1, 0],
y: [0, window?.innerHeight || 600],
rotate: [0, Math.random() * 720 - 360],
scale: [1, 0.8, 0.3],
x: [0, (Math.random() - 0.5) * 200],
}}
transition={{ duration: 2.5, delay, ease: 'easeOut' }}
/>
);
}
/* ── Render: Practice Mode ───────────────────────────────────────── */
const renderPractice = () => {
if (!selectedTopic) return null;
const totalQ = selectedTopic.questionCount || 5;
const totalQ = practiceQuestions.length;
if (practiceComplete) {
const pct = Math.round((practiceScore / totalQ) * 100);
if (totalQ === 0) {
return (
<div className="max-w-md mx-auto space-y-6">
<Card className="border-0 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-6 text-center">
<FileText className="h-16 w-16 text-white/80 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">
{t('practice.no_questions')}
</h2>
<p className="text-emerald-100 text-sm">
{t('practice.no_questions_desc')}
</p>
</div>
<CardContent className="p-6 text-center space-y-3">
<Button onClick={handleGenerateQuestions} className="w-full gap-1 bg-emerald-600 hover:bg-emerald-700">
<Sparkles className="h-4 w-4" />
{t('practice.generate_questions')}
</Button>
<Button onClick={handleBack} variant="outline" className="w-full gap-1">
<ArrowLeft className="h-4 w-4" />
{t('subjects.back_to_subject')}
</Button>
</CardContent>
</Card>
</div>
);
}
if (practiceComplete) {
const pct = Math.round((practiceScore / totalQ) * 100);
const incorrectCount = totalQ - practiceScore;
const scoreMessage = pct === 100
? t('practice.perfect_score')
: pct >= 80
? t('practice.great_score')
: pct >= 50
? t('practice.good_score')
: t('practice.needs_practice');
return (
<div className="max-w-lg mx-auto space-y-6">
{/* Confetti overlay */}
<AnimatePresence>
{showConfetti && (
<motion.div
className="fixed inset-0 pointer-events-none z-50 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{Array.from({ length: 40 }).map((_, i) => (
<ConfettiParticle
key={i}
delay={Math.random() * 0.8}
color={['#10b981', '#fbbf24', '#ef4444', '#3b82f6', '#8b5cf6', '#ec4899'][i % 6]}
/>
))}
</motion.div>
)}
</AnimatePresence>
<Card className="border-0 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-6 text-center relative">
{pct >= 80 && (
<motion.div
className="absolute top-2 right-4"
animate={{ scale: [1, 1.2, 1], rotate: [0, 10, -10, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
>
<PartyPopper className="h-8 w-8 text-yellow-300" />
</motion.div>
)}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', duration: 0.8, stiffness: 200 }}
>
<Award className="h-20 w-20 text-white mx-auto mb-4 drop-shadow-lg" />
{pct >= 80 ? (
<Trophy className="h-20 w-20 text-yellow-300 mx-auto mb-4 drop-shadow-lg" />
) : (
<Award className="h-20 w-20 text-white mx-auto mb-4 drop-shadow-lg" />
)}
</motion.div>
<h2 className="text-2xl font-bold text-white mb-2">
{t('subjects.practice_complete')}
{t('practice.results')}
</h2>
<p className="text-emerald-100">
{t('subjects.score')}: {practiceScore}/{totalQ} ({pct}%)
<p className="text-emerald-100 text-lg">
{practiceScore}/{totalQ} ({pct}%)
</p>
<motion.p
className="text-white font-semibold mt-2"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
{scoreMessage}
</motion.p>
</div>
<CardContent className="p-6 text-center">
<CardContent className="p-6">
{/* Circular progress */}
<div className="relative w-32 h-32 mx-auto mb-4">
<div className="relative w-32 h-32 mx-auto mb-6">
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" className="text-gray-200 dark:text-gray-700" strokeWidth="8" />
<motion.circle
cx="50" cy="50" r="40" fill="none"
stroke={pct >= 70 ? '#10b981' : pct >= 40 ? '#f59e0b' : '#ef4444'}
stroke={pct >= 80 ? '#10b981' : pct >= 50 ? '#f59e0b' : '#ef4444'}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 40}`}
@@ -1225,12 +1465,88 @@ export default function SubjectsView() {
</motion.span>
</div>
</div>
<div className="flex gap-3 justify-center">
<Button onClick={handleStartPractice} variant="outline" className="gap-1">
{/* Stats row */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<p className="text-2xl font-bold text-emerald-600">{practiceScore}</p>
<p className="text-xs text-gray-500">{t('practice.correct_answers')}</p>
</div>
<div className="text-center p-3 rounded-lg bg-rose-50 dark:bg-rose-900/20">
<p className="text-2xl font-bold text-rose-600">{incorrectCount}</p>
<p className="text-xs text-gray-500">{t('practice.incorrect_answers')}</p>
</div>
<div className="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20">
<p className="text-2xl font-bold text-amber-600">{pct}%</p>
<p className="text-xs text-gray-500">{t('practice.percentage')}</p>
</div>
</div>
{/* Detailed breakdown */}
<div className="mb-6">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<Info className="h-4 w-4 text-emerald-600" />
{t('practice.detailed_breakdown')}
</h3>
<ScrollArea className="max-h-64">
<div className="space-y-2">
{answerHistory.map((entry, idx) => {
const q = practiceQuestions[entry.questionIndex];
if (!q) return null;
return (
<div
key={idx}
className={`p-3 rounded-lg border text-sm ${
entry.isCorrect
? 'border-emerald-200 bg-emerald-50/50 dark:border-emerald-800 dark:bg-emerald-900/10'
: 'border-rose-200 bg-rose-50/50 dark:border-rose-800 dark:bg-rose-900/10'
}`}
>
<div className="flex items-start gap-2">
{entry.isCorrect ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5 shrink-0" />
) : (
<XCircle className="h-4 w-4 text-rose-500 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">
{q.question}
</p>
{!entry.isCorrect && (
<div className="mt-1 text-xs space-y-0.5">
<p className="text-rose-600 dark:text-rose-400">
{t('practice.your_answer')}: {q.options[entry.selectedAnswer]}
</p>
<p className="text-emerald-600 dark:text-emerald-400">
{t('practice.correct_answer')}: {q.options[q.correctAnswer]}
</p>
</div>
)}
{q.explanation && (
<p className="text-xs text-gray-500 mt-1">{q.explanation}</p>
)}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
{/* Action buttons */}
<div className="flex flex-col gap-2">
<Button onClick={handleStartPractice} className="gap-1 bg-emerald-600 hover:bg-emerald-700">
<RefreshCw className="h-4 w-4" />
{t('subjects.start_practice')}
{t('practice.retry')}
</Button>
<Button onClick={handleBack} className="gap-1 bg-emerald-600 hover:bg-emerald-700">
{incorrectCount > 0 && (
<Button onClick={handleRetryIncorrect} variant="outline" className="gap-1 border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:text-emerald-400 dark:hover:bg-emerald-900/20">
<RotateCcw className="h-4 w-4" />
{t('practice.retry_incorrect')} ({incorrectCount})
</Button>
)}
<Button onClick={handleBack} variant="ghost" className="gap-1">
<ArrowLeft className="h-4 w-4" />
{t('subjects.back_to_subject')}
</Button>
@@ -1241,8 +1557,10 @@ export default function SubjectsView() {
);
}
// Generate mock options for the current question
const mockOptions = ['A', 'B', 'C', 'D'];
const currentQ = practiceQuestions[currentQuestion];
if (!currentQ) return null;
const optionLabels = ['A', 'B', 'C', 'D'];
return (
<div className="max-w-md mx-auto space-y-6">
@@ -1268,19 +1586,24 @@ export default function SubjectsView() {
</div>
{/* Question navigation dots */}
<div className="flex items-center gap-1 mt-2 justify-center">
{Array.from({ length: totalQ }).map((_, i) => (
<motion.div
key={i}
className={`h-2 rounded-full transition-all duration-300 ${
i === currentQuestion
? 'w-6 bg-emerald-500'
: i < currentQuestion
? 'w-2 bg-emerald-300 dark:bg-emerald-700'
: 'w-2 bg-gray-300 dark:bg-gray-600'
}`}
layout
/>
))}
{practiceQuestions.map((_, i) => {
const historyEntry = answerHistory.find((h) => h.questionIndex === i);
return (
<motion.div
key={i}
className={`h-2 rounded-full transition-all duration-300 ${
i === currentQuestion
? 'w-6 bg-emerald-500'
: historyEntry
? historyEntry.isCorrect
? 'w-2 bg-emerald-400'
: 'w-2 bg-rose-400'
: 'w-2 bg-gray-300 dark:bg-gray-600'
}`}
layout
/>
);
})}
</div>
</div>
</div>
@@ -1289,21 +1612,21 @@ export default function SubjectsView() {
<Card className="border-emerald-200 dark:border-emerald-800">
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{selectedTopic.title} - Frage {currentQuestion + 1}
{t('practice.question')} {currentQuestion + 1}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Was ist die richtige Antwort auf diese Frage zum Thema {selectedTopic.title}?
{currentQ.question}
</p>
<div className="space-y-3">
{mockOptions.map((option) => {
const isSelected = selectedAnswer === option;
const isCorrect = showResult && option === 'A';
const isWrong = showResult && isSelected && option !== 'A';
{currentQ.options.map((option, idx) => {
const isSelected = selectedAnswer === idx;
const isCorrect = showResult && idx === currentQ.correctAnswer;
const isWrong = showResult && isSelected && idx !== currentQ.correctAnswer;
return (
<motion.button
key={option}
onClick={() => !showResult && handleAnswer(option)}
key={idx}
onClick={() => !showResult && handleAnswer(idx)}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
isCorrect
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
@@ -1317,12 +1640,18 @@ export default function SubjectsView() {
whileTap={!showResult ? { scale: 0.99 } : {}}
>
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-sm font-medium">
{option}
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
isCorrect
? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
: isWrong
? 'bg-rose-100 dark:bg-rose-900/40 text-rose-700 dark:text-rose-300'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
{optionLabels[idx]}
</span>
<span className="text-sm">Antwortmoeglichkeit {option}</span>
{isCorrect && <Check className="h-4 w-4 text-emerald-500 ml-auto" />}
{isWrong && <X className="h-4 w-4 text-rose-500 ml-auto" />}
<span className="text-sm flex-1">{option}</span>
{isCorrect && <Check className="h-4 w-4 text-emerald-500 ml-auto shrink-0" />}
{isWrong && <X className="h-4 w-4 text-rose-500 ml-auto shrink-0" />}
</div>
</motion.button>
);
@@ -1333,24 +1662,29 @@ export default function SubjectsView() {
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4"
className="mt-4 space-y-3"
>
<div className={`p-3 rounded-lg text-sm ${
selectedAnswer === 'A'
selectedAnswer === currentQ.correctAnswer
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300'
: 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
}`}>
{selectedAnswer === 'A'
? t('subjects.correct_answer')
: t('subjects.wrong_answer')}
<p className="font-medium">
{selectedAnswer === currentQ.correctAnswer
? t('practice.correct')
: t('practice.incorrect')}
</p>
{currentQ.explanation && (
<p className="mt-1 text-xs opacity-80">{currentQ.explanation}</p>
)}
</div>
<Button
onClick={handleNextQuestion}
className="mt-3 w-full bg-emerald-600 hover:bg-emerald-700"
className="w-full bg-emerald-600 hover:bg-emerald-700"
>
{currentQuestion + 1 >= totalQ
? t('subjects.finish_practice')
: t('subjects.next_question')}
? t('practice.finish')
: t('practice.next_question')}
</Button>
</motion.div>
)}
File diff suppressed because it is too large. Load diff
+44 -1
View File
@@ -3110,11 +3110,13 @@ export interface CompetitionData {
rules: string | null;
isPublic: boolean;
isDemo: boolean;
isFederation: boolean;
federationSchedule: string | null;
createdById: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
school?: { id: string; name: string };
school?: { id: string; name: string; primaryColor?: string | null };
createdBy?: { id: string; firstName: string; lastName: string };
subject?: { id: string; name: string } | null;
_count?: { participants: number; rewards: number };
@@ -3202,6 +3204,47 @@ export function fetchCompetitions(
return apiGet<{ competitions: CompetitionData[]; total: number }>(`/api/competitions?${params.toString()}`);
}
export interface FederationLeaderboardEntry {
schoolId: string;
schoolName: string;
teamScore: number;
participantCount: number;
rank: number;
}
export function fetchFederationCompetitions(
category?: string,
schedule?: string,
status?: string,
limit?: number,
offset?: number
): Promise<{ competitions: CompetitionData[]; total: number; schoolLeaderboard: FederationLeaderboardEntry[] }> {
const params = new URLSearchParams();
if (category) params.set('category', category);
if (schedule) params.set('schedule', schedule);
if (status) params.set('status', status);
if (limit) params.set('limit', String(limit));
if (offset) params.set('offset', String(offset));
return apiGet<{ competitions: CompetitionData[]; total: number; schoolLeaderboard: FederationLeaderboardEntry[] }>(`/api/competitions/federation?${params.toString()}`);
}
export function createFederationCompetition(data: {
schoolId: string;
title: string;
description?: string | null;
category: string;
subjectId?: string | null;
schedule: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
maxParticipants?: number | null;
rules?: string | null;
isPublic?: boolean;
}): Promise<CompetitionData> {
return apiPost<CompetitionData>('/api/competitions/federation', data);
}
export function fetchPublicCompetitions(
limit?: number,
offset?: number
+368
View File
@@ -4630,6 +4630,190 @@ const de: TranslationDict = {
'subjects.crawl_admin_only': 'Nur fuer Administratoren',
'subjects.crawl_progress': 'Crawl-Fortschritt',
'subjects.crawl_complete': 'Crawl abgeschlossen',
// ─── Practice Exercises ──────────────────────────────────────────────
'practice.start': 'Uebung starten',
'practice.generate_questions': 'Fragen mit KI generieren',
'practice.question': 'Frage',
'practice.correct': 'Richtig!',
'practice.incorrect': 'Leider falsch',
'practice.explanation': 'Erklaerung',
'practice.score': 'Punktzahl',
'practice.results': 'Ergebnisse',
'practice.retry': 'Nochmal versuchen',
'practice.retry_incorrect': 'Falsche Fragen wiederholen',
'practice.next_question': 'Naechste Frage',
'practice.finish': 'Uebung beenden',
'practice.generating': 'Fragen werden generiert...',
'practice.no_questions': 'Keine Fragen vorhanden',
'practice.no_questions_desc': 'Generiere Fragen mit KI oder waehle ein anderes Thema.',
'practice.celebration': 'Grossartig!',
'practice.try_again': 'Erneut versuchen',
'practice.question_count_label': 'Anzahl der Fragen',
'practice.difficulty_label': 'Schwierigkeit',
'practice.grade_level': 'Klassenstufe',
'practice.answered': 'Beantwortet',
'practice.correct_answers': 'Richtige Antworten',
'practice.incorrect_answers': 'Falsche Antworten',
'practice.percentage': 'Prozent',
'practice.detailed_breakdown': 'Detaillierte Auswertung',
'practice.your_answer': 'Deine Antwort',
'practice.correct_answer': 'Richtige Antwort',
'practice.rate_limit_reached': 'Tageslimit erreicht',
'practice.rate_limit_desc': 'Du hast das Limit von {limit} Fragen-Generierungen pro Tag erreicht. Bitte versuche es morgen erneut.',
'practice.generation_error': 'Fehler beim Generieren',
'practice.generation_error_desc': 'Die Fragen konnten nicht generiert werden. Bitte versuche es erneut.',
'practice.existing_questions': 'Vorhandene Fragen',
'practice.new_questions': 'Neue Fragen generieren',
'practice.option_a': 'A',
'practice.option_b': 'B',
'practice.option_c': 'C',
'practice.option_d': 'D',
'practice.perfect_score': 'Perfekt! Alle Fragen richtig!',
'practice.great_score': 'Sehr gut! Weiter so!',
'practice.good_score': 'Gut gemacht! Ein wenig mehr Uebung.',
'practice.needs_practice': 'Weiter ueben! Du schaffst das!',
'practice.xp_earned': '{xp} XP verdient',
// ─── Enhanced Virtual Character ──────────────────────────────────────
'character.accessories': 'Zubehoer',
'character.hat': 'Hut',
'character.glasses': 'Brille',
'character.scarf': 'Schal',
'character.cape': 'Umhang',
'character.wand': 'Zauberstab',
'character.unlock_level': 'Freischalten ab Level {level}',
'character.customize': 'Anpassen',
'character.color': 'Farbe',
'character.greeting': 'Hallo {name}!',
'character.daily_tip': 'Tipp des Tages',
'character.exam_reminder': 'Pruefungserinnerung',
'character.reaction_correct': 'Richtig! Weiter so!',
'character.reaction_incorrect': 'Das war leider falsch. Versuch es nochmal!',
'character.reaction_celebrating': 'Fantastisch! Du bist toll!',
'character.reaction_thinking': 'Hmm, lass mich darueber nachdenken...',
'character.chat': 'Mit {name} chatten',
'character.chat_placeholder': 'Frage mich etwas...',
'character.send': 'Senden',
'character.close_chat': 'Chat schliessen',
'character.mood_idle': 'Entspannt',
'character.mood_talking': 'Gespraechig',
'character.accessory_locked': 'Gesperrt',
'character.accessory_unlocked': 'Freigeschaltet',
'character.xp_to_next': 'Noch {xp} XP bis Level {level}',
'character.daily_tip_1': 'Regelmaessiges Lernen ist besser als langes Lernen am Stueck!',
'character.daily_tip_2': 'Mach alle 25 Minuten eine kurze Pause!',
'character.daily_tip_3': 'Wiederholung ist der Schluessel zum Erfolg!',
'character.daily_tip_4': 'Schreibe dir das Wichtigste auf!',
'character.daily_tip_5': 'Erklaere anderen, was du gelernt hast!',
'character.exam_reminder_msg': 'Vergiss nicht: Du hast bald eine Pruefung!',
'character.encouragement_1': 'Du machst das super!',
'character.encouragement_2': 'Weiter so, du bist auf dem richtigen Weg!',
'character.encouragement_3': 'Jeder Schritt zaehlt!',
'character.encouragement_4': 'Lass dich nicht entmutigen!',
'character.encouragement_5': 'Uebung macht den Meister!',
'character.confetti': 'Konfetti',
'character.level_up': 'Level aufgestiegen!',
'character.new_level': 'Du bist jetzt Level {level}!',
'character.animation_idle': 'Schwebend',
'character.animation_thinking': 'Nachdenkend',
'character.animation_celebrating': 'Feiernd',
'character.animation_sleeping': 'Schlafend',
'character.animation_talking': 'Sprechend',
'character.select_character': 'Charakter auswaehlen',
'character.preview': 'Vorschau',
'character.save': 'Speichern',
'character.saved': 'Gespeichert!',
'character.name': 'Name',
'character.level': 'Level',
'character.xp': 'XP',
'character.select': 'Auswaehlen',
'character.owl': 'Eule',
'character.dragon': 'Drache',
'character.robot': 'Roboter',
'character.cat': 'Katze',
'character.wizard': 'Magier',
'character.mood_happy': 'Froehlich',
'character.mood_excited': 'Aufgeregt',
'character.mood_thinking': 'Nachdenkend',
'character.mood_sleepy': 'Muede',
'character.mood_celebrating': 'Feiernd',
'character.greeting_morning': 'Guten Morgen!',
'character.greeting_afternoon': 'Guten Tag!',
'character.greeting_evening': 'Guten Abend!',
'character.idle_message_1': 'Lust auf eine Uebung?',
'character.idle_message_2': 'Lass uns etwas lernen!',
'character.idle_message_3': 'Bereit fuer eine Herausforderung?',
// ─── Federation (Inter-School Competition) ────────────────────────
'federation.title': 'Schul-Wettbewerbsverbund',
'federation.leaderboard': 'Bestenliste',
'federation.school_ranking': 'Schulrangliste',
'federation.team_score': 'Team-Punktzahl',
'federation.join': 'Teilnehmen',
'federation.create': 'Verbands-Wettbewerb erstellen',
'federation.math_olympiad': 'Mathematik-Olympiade',
'federation.science_bowl': 'Wissenschafts-Bowl',
'federation.language_quiz': 'Sprach-Quiz',
'federation.general_knowledge': 'Allgemeinwissen',
'federation.weekly': 'Woechentlich',
'federation.monthly': 'Monatlich',
'federation.quarterly': 'Vierteljaehrlich',
'federation.school_rank': 'Schulrang',
'federation.participants': 'Teilnehmer',
'federation.competition_type': 'Wettbewerbstyp',
'federation.schedule': 'Zeitplan',
'federation.no_district': 'Ihre Schule gehoert keinem Bezirk an',
'federation.no_competitions': 'Keine Verbands-Wettbewerbe vorhanden',
'federation.your_school': 'Ihre Schule',
'federation.points': 'Punkte',
'federation.top_students': 'Top-Schueler',
'federation.register_success': 'Erfolgreich angemeldet',
'federation.create_success': 'Verbands-Wettbewerb erstellt',
'federation.error_create': 'Fehler beim Erstellen des Wettbewerbs',
'federation.error_join': 'Fehler bei der Anmeldung',
'federation.description': 'Schulen im selben Bezirk treten gegeneinander an',
'federation.category_label': 'Kategorie',
'federation.district_schools': 'Bezirksschulen',
'federation.active_federation': 'Aktive Verbands-Wettbewerbe',
'federation.upcoming': 'Anstehende Wettbewerbe',
'federation.completed': 'Abgeschlossene Wettbewerbe',
// ─── Enhanced Communication ───────────────────────────────────────
'communication.voice_call': 'Sprachanruf',
'communication.voice_message': 'Sprachnachricht',
'communication.play': 'Abspielen',
'communication.share_notes': 'Notizen teilen',
'communication.select_notebook': 'Heft auswaehlen',
'communication.shared_notes': 'Geteilte Notizen',
'communication.upload_file': 'Datei hochladen',
'communication.drag_drop': 'Datei hierher ziehen oder klicken',
'communication.teacher_notes': 'Lehrer-Notizen',
'communication.private_notes': 'Private Notizen',
'communication.save_note': 'Notiz speichern',
'communication.notes_saved': 'Notiz gespeichert',
'communication.note_shared': 'Notiz geteilt',
'communication.file_shared': 'Datei geteilt',
'communication.recording': 'Aufnahme...',
'communication.record_voice': 'Sprachnachricht aufnehmen',
'communication.stop_recording': 'Aufnahme stoppen',
'communication.voice_duration': 'Dauer',
'communication.attach_file': 'Datei anhaengen',
'communication.supported_types': 'Unterstuetzte Formate: PDF, Bilder, Dokumente',
'communication.file_size_limit': 'Maximale Dateigroesse: 10 MB',
'communication.no_notebooks': 'Keine Hefte vorhanden',
'communication.notebook_pages': 'Seiten',
'communication.teacher_notes_panel': 'Lehrer-Notizen (nicht sichtbar fuer Schueler)',
'communication.teacher_notes_placeholder': 'Private Notizen zu diesem Gespraech...',
'communication.notes_history': 'Notizverlauf',
'communication.add_note': 'Notiz hinzufuegen',
'communication.delete_note': 'Notiz loeschen',
'communication.note_added': 'Notiz hinzugefuegt',
'communication.note_deleted': 'Notiz geloescht',
'communication.drop_zone': 'Datei ablegen',
'communication.voice_recording_error': 'Mikrofonzugriff verweigert',
'communication.play_voice': 'Sprachnachricht abspielen',
'communication.pause_voice': 'Sprachnachricht pausieren',
};
const en: TranslationDict = {
@@ -9258,6 +9442,190 @@ const en: TranslationDict = {
'subjects.crawl_admin_only': 'Admin Only',
'subjects.crawl_progress': 'Crawl Progress',
'subjects.crawl_complete': 'Crawl Complete',
// ─── Practice Exercises ──────────────────────────────────────────────
'practice.start': 'Start Practice',
'practice.generate_questions': 'Generate Questions with AI',
'practice.question': 'Question',
'practice.correct': 'Correct!',
'practice.incorrect': 'Incorrect',
'practice.explanation': 'Explanation',
'practice.score': 'Score',
'practice.results': 'Results',
'practice.retry': 'Try Again',
'practice.retry_incorrect': 'Retry Incorrect Questions',
'practice.next_question': 'Next Question',
'practice.finish': 'Finish Practice',
'practice.generating': 'Generating questions...',
'practice.no_questions': 'No Questions Available',
'practice.no_questions_desc': 'Generate questions with AI or choose a different topic.',
'practice.celebration': 'Awesome!',
'practice.try_again': 'Try Again',
'practice.question_count_label': 'Number of Questions',
'practice.difficulty_label': 'Difficulty',
'practice.grade_level': 'Grade Level',
'practice.answered': 'Answered',
'practice.correct_answers': 'Correct Answers',
'practice.incorrect_answers': 'Incorrect Answers',
'practice.percentage': 'Percentage',
'practice.detailed_breakdown': 'Detailed Breakdown',
'practice.your_answer': 'Your Answer',
'practice.correct_answer': 'Correct Answer',
'practice.rate_limit_reached': 'Daily Limit Reached',
'practice.rate_limit_desc': 'You have reached the limit of {limit} question generations per day. Please try again tomorrow.',
'practice.generation_error': 'Generation Error',
'practice.generation_error_desc': 'Questions could not be generated. Please try again.',
'practice.existing_questions': 'Existing Questions',
'practice.new_questions': 'Generate New Questions',
'practice.option_a': 'A',
'practice.option_b': 'B',
'practice.option_c': 'C',
'practice.option_d': 'D',
'practice.perfect_score': 'Perfect! All questions correct!',
'practice.great_score': 'Great job! Keep it up!',
'practice.good_score': 'Good work! A little more practice.',
'practice.needs_practice': 'Keep practicing! You can do it!',
'practice.xp_earned': '{xp} XP earned',
// ─── Enhanced Virtual Character ──────────────────────────────────────
'character.accessories': 'Accessories',
'character.hat': 'Hat',
'character.glasses': 'Glasses',
'character.scarf': 'Scarf',
'character.cape': 'Cape',
'character.wand': 'Wand',
'character.unlock_level': 'Unlocks at Level {level}',
'character.customize': 'Customize',
'character.color': 'Color',
'character.greeting': 'Hello {name}!',
'character.daily_tip': 'Tip of the Day',
'character.exam_reminder': 'Exam Reminder',
'character.reaction_correct': 'Correct! Keep going!',
'character.reaction_incorrect': 'That was incorrect. Try again!',
'character.reaction_celebrating': 'Fantastic! You are amazing!',
'character.reaction_thinking': 'Hmm, let me think about that...',
'character.chat': 'Chat with {name}',
'character.chat_placeholder': 'Ask me something...',
'character.send': 'Send',
'character.close_chat': 'Close Chat',
'character.mood_idle': 'Relaxed',
'character.mood_talking': 'Talkative',
'character.accessory_locked': 'Locked',
'character.accessory_unlocked': 'Unlocked',
'character.xp_to_next': '{xp} XP until Level {level}',
'character.daily_tip_1': 'Regular studying is better than cramming!',
'character.daily_tip_2': 'Take a short break every 25 minutes!',
'character.daily_tip_3': 'Repetition is the key to success!',
'character.daily_tip_4': 'Write down the most important things!',
'character.daily_tip_5': 'Explain to others what you have learned!',
'character.exam_reminder_msg': 'Don\'t forget: You have an exam soon!',
'character.encouragement_1': 'You are doing great!',
'character.encouragement_2': 'Keep going, you are on the right track!',
'character.encouragement_3': 'Every step counts!',
'character.encouragement_4': 'Don\'t be discouraged!',
'character.encouragement_5': 'Practice makes perfect!',
'character.confetti': 'Confetti',
'character.level_up': 'Level Up!',
'character.new_level': 'You are now Level {level}!',
'character.animation_idle': 'Floating',
'character.animation_thinking': 'Thinking',
'character.animation_celebrating': 'Celebrating',
'character.animation_sleeping': 'Sleeping',
'character.animation_talking': 'Talking',
'character.select_character': 'Select Character',
'character.preview': 'Preview',
'character.save': 'Save',
'character.saved': 'Saved!',
'character.name': 'Name',
'character.level': 'Level',
'character.xp': 'XP',
'character.select': 'Select',
'character.owl': 'Owl',
'character.dragon': 'Dragon',
'character.robot': 'Robot',
'character.cat': 'Cat',
'character.wizard': 'Wizard',
'character.mood_happy': 'Happy',
'character.mood_excited': 'Excited',
'character.mood_thinking': 'Thinking',
'character.mood_sleepy': 'Sleepy',
'character.mood_celebrating': 'Celebrating',
'character.greeting_morning': 'Good Morning!',
'character.greeting_afternoon': 'Good Afternoon!',
'character.greeting_evening': 'Good Evening!',
'character.idle_message_1': 'Want to practice?',
'character.idle_message_2': 'Let\'s learn something!',
'character.idle_message_3': 'Ready for a challenge?',
// ─── Federation (Inter-School Competition) ────────────────────────
'federation.title': 'Inter-School Competition Federation',
'federation.leaderboard': 'Leaderboard',
'federation.school_ranking': 'School Ranking',
'federation.team_score': 'Team Score',
'federation.join': 'Join Competition',
'federation.create': 'Create Federation Competition',
'federation.math_olympiad': 'Math Olympiad',
'federation.science_bowl': 'Science Bowl',
'federation.language_quiz': 'Language Quiz',
'federation.general_knowledge': 'General Knowledge',
'federation.weekly': 'Weekly',
'federation.monthly': 'Monthly',
'federation.quarterly': 'Quarterly',
'federation.school_rank': 'School Rank',
'federation.participants': 'Participants',
'federation.competition_type': 'Competition Type',
'federation.schedule': 'Schedule',
'federation.no_district': 'Your school is not part of a district',
'federation.no_competitions': 'No federation competitions available',
'federation.your_school': 'Your School',
'federation.points': 'Points',
'federation.top_students': 'Top Students',
'federation.register_success': 'Successfully registered',
'federation.create_success': 'Federation competition created',
'federation.error_create': 'Error creating competition',
'federation.error_join': 'Error joining competition',
'federation.description': 'Schools in the same district compete against each other',
'federation.category_label': 'Category',
'federation.district_schools': 'District Schools',
'federation.active_federation': 'Active Federation Competitions',
'federation.upcoming': 'Upcoming Competitions',
'federation.completed': 'Completed Competitions',
// ─── Enhanced Communication ───────────────────────────────────────
'communication.voice_call': 'Voice Call',
'communication.voice_message': 'Voice Message',
'communication.play': 'Play',
'communication.share_notes': 'Share Notes',
'communication.select_notebook': 'Select Notebook',
'communication.shared_notes': 'Shared Notes',
'communication.upload_file': 'Upload File',
'communication.drag_drop': 'Drag and drop file here or click',
'communication.teacher_notes': 'Teacher Notes',
'communication.private_notes': 'Private Notes',
'communication.save_note': 'Save Note',
'communication.notes_saved': 'Note saved',
'communication.note_shared': 'Note shared',
'communication.file_shared': 'File shared',
'communication.recording': 'Recording...',
'communication.record_voice': 'Record voice message',
'communication.stop_recording': 'Stop recording',
'communication.voice_duration': 'Duration',
'communication.attach_file': 'Attach file',
'communication.supported_types': 'Supported formats: PDF, images, documents',
'communication.file_size_limit': 'Maximum file size: 10 MB',
'communication.no_notebooks': 'No notebooks available',
'communication.notebook_pages': 'Pages',
'communication.teacher_notes_panel': 'Teacher Notes (not visible to student)',
'communication.teacher_notes_placeholder': 'Private notes about this conversation...',
'communication.notes_history': 'Notes History',
'communication.add_note': 'Add Note',
'communication.delete_note': 'Delete Note',
'communication.note_added': 'Note added',
'communication.note_deleted': 'Note deleted',
'communication.drop_zone': 'Drop file here',
'communication.voice_recording_error': 'Microphone access denied',
'communication.play_voice': 'Play voice message',
'communication.pause_voice': 'Pause voice message',
};
const translations: Record<string, TranslationDict> = { de, en };