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:
1 parent
2f266a2143
commit
44ff170fca
12 files changed
+3655
-476
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user