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
@@ -1680,6 +1680,8 @@ model Competition {
|
|||||||
rules String? // markdown rules/description
|
rules String? // markdown rules/description
|
||||||
isPublic Boolean @default(false) // visible to all schools?
|
isPublic Boolean @default(false) // visible to all schools?
|
||||||
isDemo Boolean @default(false)
|
isDemo Boolean @default(false)
|
||||||
|
isFederation Boolean @default(false) // inter-school district federation competition
|
||||||
|
federationSchedule String? // "weekly" | "monthly" | "quarterly"
|
||||||
createdById String // teacher/admin who created
|
createdById String // teacher/admin who created
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { characterId, name, color, mood } = body;
|
const { characterId, name, color, mood, accessories } = body;
|
||||||
|
|
||||||
// Get existing character
|
// Get existing character
|
||||||
const existing = await db.virtualCharacter.findUnique({
|
const existing = await db.virtualCharacter.findUnique({
|
||||||
@@ -85,6 +85,7 @@ export async function PUT(request: Request) {
|
|||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
mood: mood || 'happy',
|
mood: mood || 'happy',
|
||||||
|
accessories: accessories || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(character);
|
return NextResponse.json(character);
|
||||||
@@ -98,6 +99,7 @@ export async function PUT(request: Request) {
|
|||||||
...(name ? { name } : {}),
|
...(name ? { name } : {}),
|
||||||
...(color ? { color } : {}),
|
...(color ? { color } : {}),
|
||||||
...(mood ? { mood } : {}),
|
...(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,
|
AlertTriangle,
|
||||||
Volume2,
|
Volume2,
|
||||||
Notebook,
|
Notebook,
|
||||||
|
Upload,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
File,
|
||||||
|
Image,
|
||||||
|
FileIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -76,6 +84,20 @@ interface Message {
|
|||||||
sender?: { id: string; firstName: string; lastName: string; role: string };
|
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() {
|
export default function CommunicationView() {
|
||||||
const currentUser = useAppStore((s) => s.currentUser);
|
const currentUser = useAppStore((s) => s.currentUser);
|
||||||
const [rooms, setRooms] = useState<CommunicationRoom[]>([]);
|
const [rooms, setRooms] = useState<CommunicationRoom[]>([]);
|
||||||
@@ -96,7 +118,6 @@ export default function CommunicationView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
// Polling every 5 seconds for new messages
|
|
||||||
const interval = setInterval(fetchRooms, 5000);
|
const interval = setInterval(fetchRooms, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchRooms]);
|
}, [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">
|
<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" />
|
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
</div>
|
</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 ${
|
<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'
|
room.status === 'active' ? 'bg-emerald-500' : room.status === 'requested' ? 'bg-amber-500' : 'bg-gray-400'
|
||||||
}`} />
|
}`} />
|
||||||
@@ -343,7 +363,6 @@ function RoomListItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{/* Unread count badge */}
|
|
||||||
{room.messages && room.messages.length > 0 && room.status === 'active' && (
|
{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">
|
<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}
|
{room.messages.length}
|
||||||
@@ -379,10 +398,23 @@ function ChatArea({
|
|||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [room, setRoom] = useState<CommunicationRoom | null>(null);
|
const [room, setRoom] = useState<CommunicationRoom | null>(null);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [teacherNotes, setTeacherNotes] = useState('');
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [showTeacherNotes, setShowTeacherNotes] = useState(false);
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(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 () => {
|
const fetchMessages = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -404,24 +436,55 @@ function ChatArea({
|
|||||||
}
|
}
|
||||||
}, [roomId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetchMessages();
|
fetchMessages();
|
||||||
fetchRoom();
|
fetchRoom();
|
||||||
|
fetchTeacherNotes();
|
||||||
const interval = setInterval(fetchMessages, 5000);
|
const interval = setInterval(fetchMessages, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchMessages, fetchRoom]);
|
}, [fetchMessages, fetchRoom, fetchTeacherNotes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async (content: string, messageType: string = 'text', metadata?: string) => {
|
||||||
if (!newMessage.trim()) return;
|
if (!content && messageType === 'text') return;
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
||||||
content: newMessage,
|
content,
|
||||||
messageType: 'text',
|
messageType,
|
||||||
|
metadata: metadata || null,
|
||||||
});
|
});
|
||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
fetchMessages();
|
fetchMessages();
|
||||||
@@ -465,38 +528,47 @@ function ChatArea({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Voice recording with Web Audio API
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
const mediaRecorder = new MediaRecorder(stream);
|
const mediaRecorder = new MediaRecorder(stream);
|
||||||
const audioChunks: BlobPart[] = [];
|
audioChunksRef.current = [];
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
audioChunks.push(event.data);
|
audioChunksRef.current.push(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorder.onstop = async () => {
|
mediaRecorder.onstop = async () => {
|
||||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||||
// In a real app, you'd upload this to a server
|
const reader = new FileReader();
|
||||||
// For now, just send a text message indicating voice note
|
reader.onloadend = async () => {
|
||||||
try {
|
const base64Audio = reader.result as string;
|
||||||
await apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
const metadata = JSON.stringify({
|
||||||
content: t('communication.voice_message'),
|
audioData: base64Audio,
|
||||||
messageType: 'voice',
|
duration: recordingDuration,
|
||||||
|
mimeType: 'audio/webm',
|
||||||
});
|
});
|
||||||
fetchMessages();
|
await sendMessage(
|
||||||
onRefresh();
|
t('communication.voice_message'),
|
||||||
} catch {
|
'voice',
|
||||||
toast.error(t('communication.error_send'));
|
metadata,
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(audioBlob);
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaRecorderRef.current = mediaRecorder;
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
mediaRecorder.start();
|
mediaRecorder.start();
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
|
setRecordingDuration(0);
|
||||||
|
|
||||||
|
recordingTimerRef.current = setInterval(() => {
|
||||||
|
setRecordingDuration((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Microphone access denied');
|
toast.error(t('communication.voice_recording_error'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -504,6 +576,124 @@ function ChatArea({
|
|||||||
if (mediaRecorderRef.current && isRecording) {
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
mediaRecorderRef.current.stop();
|
mediaRecorderRef.current.stop();
|
||||||
setIsRecording(false);
|
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}`;
|
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 (
|
return (
|
||||||
<Card className={`flex-1 flex flex-col min-h-0 ${isMobile ? 'rounded-none border-0' : ''}`}>
|
<Card className={`flex-1 flex flex-col min-h-0 ${isMobile ? 'rounded-none border-0' : ''}`}>
|
||||||
{/* Chat Header */}
|
{/* 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">
|
<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" />
|
<User className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||||
</div>
|
</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 ${
|
<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'
|
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 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
className="border-b bg-amber-50 dark:bg-amber-950/20 p-4"
|
className="border-b bg-amber-50 dark:bg-amber-950/20 p-4"
|
||||||
>
|
>
|
||||||
<Label className="text-sm font-medium">{t('communication.teacher_notes')}</Label>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Textarea
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
value={teacherNotes}
|
<StickyNote className="h-4 w-4 text-amber-500" />
|
||||||
onChange={(e) => setTeacherNotes(e.target.value)}
|
{t('communication.teacher_notes_panel')}
|
||||||
placeholder={t('communication.teacher_notes_placeholder')}
|
</Label>
|
||||||
rows={3}
|
</div>
|
||||||
className="mt-1"
|
<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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -641,7 +887,12 @@ function ChatArea({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* 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 ? (
|
{isLoadingMessages ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<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" />
|
<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 isFile = msg.messageType === 'file';
|
||||||
const isNoteShare = msg.messageType === 'note_share';
|
const isNoteShare = msg.messageType === 'note_share';
|
||||||
|
|
||||||
|
let msgMeta: Record<string, unknown> = {};
|
||||||
|
try { msgMeta = msg.metadata ? JSON.parse(msg.metadata) : {}; } catch { /* ignore */ }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -679,29 +933,64 @@ function ChatArea({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isVoice && (
|
{isVoice && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-[160px]">
|
||||||
<Volume2 className="h-4 w-4" />
|
<Button
|
||||||
<span>{t('communication.voice_message')}</span>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isFile && (
|
{isFile && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-[200px]">
|
||||||
<Paperclip className="h-4 w-4" />
|
<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'}`}>
|
||||||
<span>{t('communication.file_shared')}</span>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isNoteShare && (
|
{isNoteShare && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-[180px]">
|
||||||
<Notebook className="h-4 w-4" />
|
<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'}`}>
|
||||||
<span>{t('communication.note_shared')}</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isVoice && !isFile && !isNoteShare && <p className="text-sm">{msg.content}</p>}
|
{!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>
|
</div>
|
||||||
<p className={`text-xs text-muted-foreground mt-1 ${isOwn ? 'text-right mr-1' : 'ml-1'}`}>
|
<p className={`text-xs text-muted-foreground mt-1 ${isOwn ? 'text-right mr-1' : 'ml-1'}`}>
|
||||||
{formatTime(msg.createdAt)}
|
{formatTime(msg.createdAt)}
|
||||||
@@ -717,93 +1006,99 @@ function ChatArea({
|
|||||||
{/* Message Input */}
|
{/* Message Input */}
|
||||||
{!isAdmin && room?.status === 'active' && (
|
{!isAdmin && room?.status === 'active' && (
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
<div className="flex items-center gap-2">
|
{isRecording ? (
|
||||||
{role === 'TEACHER' && (
|
<div className="flex items-center gap-3">
|
||||||
<>
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<Button
|
<div className="h-3 w-3 rounded-full bg-rose-500 animate-pulse" />
|
||||||
variant="ghost"
|
<span className="text-sm text-rose-500 font-medium">{t('communication.recording')}</span>
|
||||||
size="icon"
|
<span className="text-sm text-muted-foreground">{formatDuration(recordingDuration)}</span>
|
||||||
className="h-9 w-9 shrink-0"
|
</div>
|
||||||
onClick={() => {
|
<Button
|
||||||
apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
variant="outline"
|
||||||
content: t('communication.note_shared'),
|
size="sm"
|
||||||
messageType: 'note_share',
|
className="text-rose-600 border-rose-200 hover:bg-rose-50"
|
||||||
}).then(() => {
|
onClick={stopRecording}
|
||||||
fetchMessages();
|
>
|
||||||
onRefresh();
|
<MicOff className="h-4 w-4 mr-1" />
|
||||||
});
|
{t('communication.stop_recording')}
|
||||||
}}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<StickyNote className="h-4 w-4" />
|
) : (
|
||||||
</Button>
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{/* Share Notes Button */}
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{role === 'STUDENT' && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 shrink-0"
|
className="h-9 w-9 shrink-0"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
apiPost(`/api/communication-rooms/${roomId}/messages`, {
|
fetchNotebooks();
|
||||||
content: t('communication.note_shared'),
|
setShowShareNotesDialog(true);
|
||||||
messageType: 'note_share',
|
|
||||||
}).then(() => {
|
|
||||||
fetchMessages();
|
|
||||||
onRefresh();
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
title={t('communication.share_notes')}
|
||||||
>
|
>
|
||||||
<StickyNote className="h-4 w-4" />
|
<Notebook className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{/* File Upload Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 shrink-0"
|
className="h-9 w-9 shrink-0"
|
||||||
onClick={isRecording ? stopRecording : startRecording}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
title={t('communication.attach_file')}
|
||||||
{isRecording ? (
|
disabled={isUploading}
|
||||||
<MicOff className="h-4 w-4 text-rose-500" />
|
>
|
||||||
) : (
|
{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" />
|
<Mic className="h-4 w-4" />
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
{/* Text Input */}
|
||||||
{isRecording && (
|
<Input
|
||||||
<div className="flex items-center gap-2 text-sm text-rose-500">
|
value={newMessage}
|
||||||
<div className="h-2 w-2 rounded-full bg-rose-500 animate-pulse" />
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
{t('communication.recording')}
|
placeholder={t('communication.type_message')}
|
||||||
</div>
|
className="flex-1"
|
||||||
)}
|
onKeyDown={(e) => {
|
||||||
{!isRecording && (
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
<>
|
e.preventDefault();
|
||||||
<Input
|
sendMessage(newMessage);
|
||||||
value={newMessage}
|
}
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
}}
|
||||||
placeholder={t('communication.type_message')}
|
/>
|
||||||
className="flex-1"
|
<Button
|
||||||
onKeyDown={(e) => {
|
onClick={() => sendMessage(newMessage)}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
disabled={isSending || !newMessage.trim()}
|
||||||
e.preventDefault();
|
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
|
||||||
sendMessage();
|
>
|
||||||
}
|
<Send className="h-4 w-4" />
|
||||||
}}
|
</Button>
|
||||||
/>
|
</div>
|
||||||
<Button
|
)}
|
||||||
onClick={sendMessage}
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||||
disabled={isSending || !newMessage.trim()}
|
<Paperclip className="h-3 w-3" />
|
||||||
className="bg-emerald-500 hover:bg-emerald-600 text-white h-9 shrink-0"
|
<span>{t('communication.supported_types')}</span>
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -827,6 +1122,57 @@ function ChatArea({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ import {
|
|||||||
Notebook,
|
Notebook,
|
||||||
History,
|
History,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Building2,
|
||||||
|
Calculator,
|
||||||
|
Microscope,
|
||||||
|
Languages,
|
||||||
|
Brain,
|
||||||
|
CalendarDays,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -111,10 +117,13 @@ import {
|
|||||||
createCompetitionReward,
|
createCompetitionReward,
|
||||||
fetchRewardClaims,
|
fetchRewardClaims,
|
||||||
claimReward,
|
claimReward,
|
||||||
|
fetchFederationCompetitions,
|
||||||
|
createFederationCompetition,
|
||||||
type CompetitionData,
|
type CompetitionData,
|
||||||
type CompetitionLeaderboardEntry,
|
type CompetitionLeaderboardEntry,
|
||||||
type CompetitionRewardData,
|
type CompetitionRewardData,
|
||||||
type RewardClaimData,
|
type RewardClaimData,
|
||||||
|
type FederationLeaderboardEntry,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -770,6 +779,27 @@ export default function CompetitionsView() {
|
|||||||
// Register
|
// Register
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
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 isAdmin = currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
const isTeacher = currentUser?.role === 'TEACHER';
|
const isTeacher = currentUser?.role === 'TEACHER';
|
||||||
const isStudent = currentUser?.role === 'STUDENT';
|
const isStudent = currentUser?.role === 'STUDENT';
|
||||||
@@ -800,6 +830,95 @@ export default function CompetitionsView() {
|
|||||||
loadCompetitions();
|
loadCompetitions();
|
||||||
}, [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 ─────────────────────────────────────────── */
|
/* ── Fetch my claims ─────────────────────────────────────────── */
|
||||||
const loadMyClaims = useCallback(async () => {
|
const loadMyClaims = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1228,6 +1347,10 @@ export default function CompetitionsView() {
|
|||||||
<TabsTrigger value="leaderboard" className="min-h-[44px]">{t('competition.leaderboard')}</TabsTrigger>
|
<TabsTrigger value="leaderboard" className="min-h-[44px]">{t('competition.leaderboard')}</TabsTrigger>
|
||||||
<TabsTrigger value="rewards" className="min-h-[44px]">{t('competition.rewards')}</TabsTrigger>
|
<TabsTrigger value="rewards" className="min-h-[44px]">{t('competition.rewards')}</TabsTrigger>
|
||||||
<TabsTrigger value="catalog" className="min-h-[44px]">{t('rewards.title')}</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>
|
</TabsList>
|
||||||
|
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab */}
|
||||||
@@ -1801,9 +1924,209 @@ export default function CompetitionsView() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{/* ── Competition Detail Dialog ──────────────────────────── */}
|
{/* ── Competition Detail Dialog ──────────────────────────── */}
|
||||||
|
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
{selectedCompetition && (
|
{selectedCompetition && (
|
||||||
@@ -2458,10 +2781,287 @@ export default function CompetitionsView() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</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) ────────────────── */
|
/* ── CompetitionRewardsSection (for rewards tab) ────────────────── */
|
||||||
|
|
||||||
function CompetitionRewardsSection({
|
function CompetitionRewardsSection({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Palette, Dumbbell, Map, Settings, FileText, MessageSquare, Shield,
|
Palette, Dumbbell, Map, Settings, FileText, MessageSquare, Shield,
|
||||||
Loader2, Sparkles, RefreshCw, Filter, ToggleLeft, ToggleRight, Send,
|
Loader2, Sparkles, RefreshCw, Filter, ToggleLeft, ToggleRight, Send,
|
||||||
AlertTriangle, CheckCircle2, Clock, XCircle, ChevronDown, Info,
|
AlertTriangle, CheckCircle2, Clock, XCircle, ChevronDown, Info,
|
||||||
|
PartyPopper, Trophy, RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -30,6 +31,13 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
/* ── Types ─────────────────────────────────────────────────────────── */
|
/* ── Types ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface PracticeQuestion {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correctAnswer: number;
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SubjectCategory {
|
interface SubjectCategory {
|
||||||
id: string;
|
id: string;
|
||||||
schoolId: string;
|
schoolId: string;
|
||||||
@@ -198,10 +206,14 @@ export default function SubjectsView() {
|
|||||||
// Practice state
|
// Practice state
|
||||||
const [isPracticing, setIsPracticing] = useState(false);
|
const [isPracticing, setIsPracticing] = useState(false);
|
||||||
const [currentQuestion, setCurrentQuestion] = useState(0);
|
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 [showResult, setShowResult] = useState(false);
|
||||||
const [practiceScore, setPracticeScore] = useState(0);
|
const [practiceScore, setPracticeScore] = useState(0);
|
||||||
const [practiceComplete, setPracticeComplete] = useState(false);
|
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
|
// Dialog state
|
||||||
const [showCategoryDialog, setShowCategoryDialog] = useState(false);
|
const [showCategoryDialog, setShowCategoryDialog] = useState(false);
|
||||||
@@ -343,6 +355,8 @@ export default function SubjectsView() {
|
|||||||
const handleTopicClick = (topic: SubjectContentItem) => {
|
const handleTopicClick = (topic: SubjectContentItem) => {
|
||||||
setSelectedTopic(topic);
|
setSelectedTopic(topic);
|
||||||
setIsPracticing(false);
|
setIsPracticing(false);
|
||||||
|
setPracticeQuestions([]);
|
||||||
|
setAnswerHistory([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
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);
|
setIsPracticing(true);
|
||||||
setCurrentQuestion(0);
|
setCurrentQuestion(0);
|
||||||
setPracticeScore(0);
|
setPracticeScore(0);
|
||||||
setSelectedAnswer(null);
|
setSelectedAnswer(null);
|
||||||
setShowResult(false);
|
setShowResult(false);
|
||||||
setPracticeComplete(false);
|
setPracticeComplete(false);
|
||||||
};
|
setAnswerHistory([]);
|
||||||
|
setShowConfetti(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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeedData = async () => {
|
const handleSeedData = async () => {
|
||||||
@@ -1086,7 +1193,36 @@ export default function SubjectsView() {
|
|||||||
return renderPractice();
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
@@ -1116,16 +1252,22 @@ export default function SubjectsView() {
|
|||||||
<p className="text-emerald-100 mt-1">{selectedTopic.description}</p>
|
<p className="text-emerald-100 mt-1">{selectedTopic.description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<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} />
|
<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>
|
</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">
|
<Button onClick={handleStartPractice} className="gap-1 bg-white text-emerald-700 hover:bg-emerald-50 shadow-md">
|
||||||
<Play className="h-4 w-4" />
|
<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>
|
</Button>
|
||||||
{isTeacher && (
|
{isTeacher && (
|
||||||
<Button
|
<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 ───────────────────────────────────────── */
|
/* ── Render: Practice Mode ───────────────────────────────────────── */
|
||||||
|
|
||||||
const renderPractice = () => {
|
const renderPractice = () => {
|
||||||
if (!selectedTopic) return null;
|
if (!selectedTopic) return null;
|
||||||
const totalQ = selectedTopic.questionCount || 5;
|
const totalQ = practiceQuestions.length;
|
||||||
|
|
||||||
if (practiceComplete) {
|
if (totalQ === 0) {
|
||||||
const pct = Math.round((practiceScore / totalQ) * 100);
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto space-y-6">
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
<Card className="border-0 shadow-xl overflow-hidden">
|
<Card className="border-0 shadow-xl overflow-hidden">
|
||||||
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 p-6 text-center">
|
<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
|
<motion.div
|
||||||
initial={{ scale: 0, rotate: -180 }}
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
animate={{ scale: 1, rotate: 0 }}
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
transition={{ type: 'spring', duration: 0.8, stiffness: 200 }}
|
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>
|
</motion.div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">
|
<h2 className="text-2xl font-bold text-white mb-2">
|
||||||
{t('subjects.practice_complete')}
|
{t('practice.results')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-emerald-100">
|
<p className="text-emerald-100 text-lg">
|
||||||
{t('subjects.score')}: {practiceScore}/{totalQ} ({pct}%)
|
{practiceScore}/{totalQ} ({pct}%)
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<CardContent className="p-6 text-center">
|
<CardContent className="p-6">
|
||||||
{/* Circular progress */}
|
{/* 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">
|
<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" />
|
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" className="text-gray-200 dark:text-gray-700" strokeWidth="8" />
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx="50" cy="50" r="40" fill="none"
|
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"
|
strokeWidth="8"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={`${2 * Math.PI * 40}`}
|
strokeDasharray={`${2 * Math.PI * 40}`}
|
||||||
@@ -1225,12 +1465,88 @@ export default function SubjectsView() {
|
|||||||
</motion.span>
|
</motion.span>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
{t('subjects.start_practice')}
|
{t('practice.retry')}
|
||||||
</Button>
|
</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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('subjects.back_to_subject')}
|
{t('subjects.back_to_subject')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1241,8 +1557,10 @@ export default function SubjectsView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate mock options for the current question
|
const currentQ = practiceQuestions[currentQuestion];
|
||||||
const mockOptions = ['A', 'B', 'C', 'D'];
|
if (!currentQ) return null;
|
||||||
|
|
||||||
|
const optionLabels = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto space-y-6">
|
<div className="max-w-md mx-auto space-y-6">
|
||||||
@@ -1268,19 +1586,24 @@ export default function SubjectsView() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Question navigation dots */}
|
{/* Question navigation dots */}
|
||||||
<div className="flex items-center gap-1 mt-2 justify-center">
|
<div className="flex items-center gap-1 mt-2 justify-center">
|
||||||
{Array.from({ length: totalQ }).map((_, i) => (
|
{practiceQuestions.map((_, i) => {
|
||||||
<motion.div
|
const historyEntry = answerHistory.find((h) => h.questionIndex === i);
|
||||||
key={i}
|
return (
|
||||||
className={`h-2 rounded-full transition-all duration-300 ${
|
<motion.div
|
||||||
i === currentQuestion
|
key={i}
|
||||||
? 'w-6 bg-emerald-500'
|
className={`h-2 rounded-full transition-all duration-300 ${
|
||||||
: i < currentQuestion
|
i === currentQuestion
|
||||||
? 'w-2 bg-emerald-300 dark:bg-emerald-700'
|
? 'w-6 bg-emerald-500'
|
||||||
: 'w-2 bg-gray-300 dark:bg-gray-600'
|
: historyEntry
|
||||||
}`}
|
? historyEntry.isCorrect
|
||||||
layout
|
? 'w-2 bg-emerald-400'
|
||||||
/>
|
: 'w-2 bg-rose-400'
|
||||||
))}
|
: 'w-2 bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
layout
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1289,21 +1612,21 @@ export default function SubjectsView() {
|
|||||||
<Card className="border-emerald-200 dark:border-emerald-800">
|
<Card className="border-emerald-200 dark:border-emerald-800">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<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>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mockOptions.map((option) => {
|
{currentQ.options.map((option, idx) => {
|
||||||
const isSelected = selectedAnswer === option;
|
const isSelected = selectedAnswer === idx;
|
||||||
const isCorrect = showResult && option === 'A';
|
const isCorrect = showResult && idx === currentQ.correctAnswer;
|
||||||
const isWrong = showResult && isSelected && option !== 'A';
|
const isWrong = showResult && isSelected && idx !== currentQ.correctAnswer;
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={option}
|
key={idx}
|
||||||
onClick={() => !showResult && handleAnswer(option)}
|
onClick={() => !showResult && handleAnswer(idx)}
|
||||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
|
||||||
isCorrect
|
isCorrect
|
||||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
||||||
@@ -1317,12 +1640,18 @@ export default function SubjectsView() {
|
|||||||
whileTap={!showResult ? { scale: 0.99 } : {}}
|
whileTap={!showResult ? { scale: 0.99 } : {}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
{option}
|
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>
|
||||||
<span className="text-sm">Antwortmoeglichkeit {option}</span>
|
<span className="text-sm flex-1">{option}</span>
|
||||||
{isCorrect && <Check className="h-4 w-4 text-emerald-500 ml-auto" />}
|
{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" />}
|
{isWrong && <X className="h-4 w-4 text-rose-500 ml-auto shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
@@ -1333,24 +1662,29 @@ export default function SubjectsView() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="mt-4"
|
className="mt-4 space-y-3"
|
||||||
>
|
>
|
||||||
<div className={`p-3 rounded-lg text-sm ${
|
<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-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'
|
: 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
|
||||||
}`}>
|
}`}>
|
||||||
{selectedAnswer === 'A'
|
<p className="font-medium">
|
||||||
? t('subjects.correct_answer')
|
{selectedAnswer === currentQ.correctAnswer
|
||||||
: t('subjects.wrong_answer')}
|
? t('practice.correct')
|
||||||
|
: t('practice.incorrect')}
|
||||||
|
</p>
|
||||||
|
{currentQ.explanation && (
|
||||||
|
<p className="mt-1 text-xs opacity-80">{currentQ.explanation}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNextQuestion}
|
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
|
{currentQuestion + 1 >= totalQ
|
||||||
? t('subjects.finish_practice')
|
? t('practice.finish')
|
||||||
: t('subjects.next_question')}
|
: t('practice.next_question')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+44
-1
@@ -3110,11 +3110,13 @@ export interface CompetitionData {
|
|||||||
rules: string | null;
|
rules: string | null;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
isDemo: boolean;
|
isDemo: boolean;
|
||||||
|
isFederation: boolean;
|
||||||
|
federationSchedule: string | null;
|
||||||
createdById: string;
|
createdById: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
school?: { id: string; name: string };
|
school?: { id: string; name: string; primaryColor?: string | null };
|
||||||
createdBy?: { id: string; firstName: string; lastName: string };
|
createdBy?: { id: string; firstName: string; lastName: string };
|
||||||
subject?: { id: string; name: string } | null;
|
subject?: { id: string; name: string } | null;
|
||||||
_count?: { participants: number; rewards: number };
|
_count?: { participants: number; rewards: number };
|
||||||
@@ -3202,6 +3204,47 @@ export function fetchCompetitions(
|
|||||||
return apiGet<{ competitions: CompetitionData[]; total: number }>(`/api/competitions?${params.toString()}`);
|
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(
|
export function fetchPublicCompetitions(
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number
|
offset?: number
|
||||||
|
|||||||
+368
@@ -4630,6 +4630,190 @@ const de: TranslationDict = {
|
|||||||
'subjects.crawl_admin_only': 'Nur fuer Administratoren',
|
'subjects.crawl_admin_only': 'Nur fuer Administratoren',
|
||||||
'subjects.crawl_progress': 'Crawl-Fortschritt',
|
'subjects.crawl_progress': 'Crawl-Fortschritt',
|
||||||
'subjects.crawl_complete': 'Crawl abgeschlossen',
|
'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 = {
|
const en: TranslationDict = {
|
||||||
@@ -9258,6 +9442,190 @@ const en: TranslationDict = {
|
|||||||
'subjects.crawl_admin_only': 'Admin Only',
|
'subjects.crawl_admin_only': 'Admin Only',
|
||||||
'subjects.crawl_progress': 'Crawl Progress',
|
'subjects.crawl_progress': 'Crawl Progress',
|
||||||
'subjects.crawl_complete': 'Crawl Complete',
|
'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 };
|
const translations: Record<string, TranslationDict> = { de, en };
|
||||||
|
|||||||
+133
@@ -746,3 +746,136 @@ Built three major features: Exam Calendar Integration, Parent Portal, and Deep S
|
|||||||
- **Parent Portal**: Child selector, pending approvals, tabbed views (exams/grades/attendance/illness/counseling/disciplinary), quick actions
|
- **Parent Portal**: Child selector, pending approvals, tabbed views (exams/grades/attendance/illness/counseling/disciplinary), quick actions
|
||||||
- **Content Crawl**: z-ai-web-dev-sdk page_reader, AI topic extraction, fallback generation, admin-only
|
- **Content Crawl**: z-ai-web-dev-sdk page_reader, AI topic extraction, fallback generation, admin-only
|
||||||
- **Plan Exam / Generate AI Test**: Buttons for exam planning and AI test generation
|
- **Plan Exam / Generate AI Test**: Buttons for exam planning and AI test generation
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: Round-22
|
||||||
|
Agent: main
|
||||||
|
Task: Round 22 - Exam Calendar, Parent Portal, Teacher Grading, Deep Styling
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Fixed schoolId auto-detect in counseling and disciplinary-committees APIs
|
||||||
|
- Added MessageSquare import to settings-view.tsx
|
||||||
|
- Built Exam Calendar Integration:
|
||||||
|
- API: /api/calendar-events/exams (GET/POST)
|
||||||
|
- Calendar view: Plan Exam button, exam filter toggle, countdown badges
|
||||||
|
- Exam type badge in calendar legend
|
||||||
|
- Red-themed exam visual config with GraduationCap icon
|
||||||
|
- Built Parent Portal:
|
||||||
|
- API: /api/parent-portal (GET)
|
||||||
|
- Parent portal view: children overview, illness approvals, grades, attendance
|
||||||
|
- Quick actions: Report illness, Request conversation
|
||||||
|
- Navigation: Added to parent role
|
||||||
|
- Built Deep Schlaukopf Content Crawling:
|
||||||
|
- API: /api/subject-contents/crawl (POST, admin-only)
|
||||||
|
- Uses z-ai-web-dev-sdk page_reader + AI to extract topics
|
||||||
|
- Crawl button in subjects-view admin section
|
||||||
|
- Built Teacher Grading Enhancements:
|
||||||
|
- Annotation API: /api/grading/annotate (GET/POST)
|
||||||
|
- Grading view: Scratch pad with DrawingCanvas, AI review button, bulk grading
|
||||||
|
- Annotation indicator on graded items
|
||||||
|
- Built Deep Styling Improvements:
|
||||||
|
- Dashboard: Upcoming exams section, AI tips section
|
||||||
|
- Settings: AI provider config, virtual character toggle, management tabs
|
||||||
|
- Layout: Favorites section, notification badges on sidebar
|
||||||
|
- Auth: VICE_PRINCIPAL demo account, feature showcase
|
||||||
|
- Attendance: Illness report integration button
|
||||||
|
- Assessments: Plan exam button, AI test generation button
|
||||||
|
- Added 150+ i18n keys
|
||||||
|
- All lints pass, dev server running
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- 6 new API route files created
|
||||||
|
- 1 new component (parent-portal-view.tsx)
|
||||||
|
- 8 components enhanced with new features
|
||||||
|
- Exam calendar integration with countdown badges
|
||||||
|
- Parent portal with children overview and illness approvals
|
||||||
|
- Teacher grading with stylus annotation and AI review
|
||||||
|
- Deep schlaukopf.de content crawling
|
||||||
|
- Pushed to GitHub
|
||||||
|
|
||||||
|
Unresolved issues / Next phase priorities:
|
||||||
|
- Need to verify all features work end-to-end in the browser
|
||||||
|
- Need to add more schlaukopf.de content (crawl deeper pages)
|
||||||
|
- Need to add more practice exercises with actual questions
|
||||||
|
- Need to enhance the virtual character with more animations
|
||||||
|
- Need to add inter-school competition federation
|
||||||
|
- Need to add parent portal enhancements (communication with teachers)
|
||||||
|
- WebSocket connection timeout errors (Caddy proxy not forwarding WebSocket upgrades)
|
||||||
|
|
||||||
|
## Task ID: 9-b - Inter-School Competitions + Enhanced Communication Builder
|
||||||
|
|
||||||
|
**Date:** 2025-01-21
|
||||||
|
**Agent:** Inter-School Competitions + Enhanced Communication Builder
|
||||||
|
**Status:** Completed
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Built the Inter-School Competition Federation system and Enhanced Communication System for CompetenceTrack. The federation system allows schools in the same district to compete against each other with school rankings and team scores. The enhanced communication system adds voice chat, note sharing, file sharing, and teacher notes to the existing communication rooms.
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
1. `src/app/api/competitions/federation/route.ts` - GET/POST for federation competitions
|
||||||
|
2. `src/app/api/communication-rooms/[id]/share-notes/route.ts` - GET/POST for sharing notebooks in communication rooms
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. `prisma/schema.prisma` - Added `isFederation` and `federationSchedule` fields to Competition model
|
||||||
|
2. `src/lib/i18n.ts` - Added 70+ i18n keys for federation and communication features (DE + EN)
|
||||||
|
3. `src/lib/api.ts` - Added `FederationLeaderboardEntry` interface, `fetchFederationCompetitions` and `createFederationCompetition` functions, updated `CompetitionData` interface with `isFederation`, `federationSchedule`, and `primaryColor` fields
|
||||||
|
4. `src/components/competitions-view.tsx` - Added Federation tab with school leaderboard, federation competition cards, create/join functionality, and category/schedule filters
|
||||||
|
5. `src/components/communication-view.tsx` - Complete rewrite with voice chat (Web Audio API), file sharing (drag-and-drop), notebook sharing, and teacher notes panel
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
#### Inter-School Competition Federation
|
||||||
|
- Schools in the same district compete against each other
|
||||||
|
- Federation leaderboard showing school rankings with team scores (sum of top 10 student scores)
|
||||||
|
- Competition types: Math Olympiad, Science Bowl, Language Quiz, General Knowledge
|
||||||
|
- Schedule: Weekly, Monthly, Quarterly competitions
|
||||||
|
- Admin/teacher can create federation competitions
|
||||||
|
- Students can join competitions with "Join Competition" button
|
||||||
|
- Federation-specific competition cards with category icons
|
||||||
|
- Create Federation Competition dialog with all fields
|
||||||
|
|
||||||
|
#### Enhanced Communication System
|
||||||
|
- **Voice Chat**: Record and send voice messages using Web Audio API, with playback controls (play/pause), duration display, and waveform visualization
|
||||||
|
- **Note Sharing**: Students can share their notebook with the teacher via the share-notes API, select notebook dialog with color-coded notebook cards
|
||||||
|
- **File Sharing**: Drag-and-drop file upload, file input button, supports PDF/images/documents, files stored as base64 in message metadata, file size validation (10 MB max)
|
||||||
|
- **Teacher Notes Panel**: Teachers can take private notes during conversations, notes are saved via TeacherNote model, notes are NOT visible to students, expandable panel in chat header
|
||||||
|
|
||||||
|
### i18n Keys Added (70+ keys)
|
||||||
|
- Federation: `federation.title`, `federation.leaderboard`, `federation.school_ranking`, `federation.team_score`, `federation.join`, `federation.create`, `federation.math_olympiad`, `federation.science_bowl`, `federation.language_quiz`, `federation.general_knowledge`, `federation.weekly`, `federation.monthly`, `federation.quarterly`, etc.
|
||||||
|
- Communication: `communication.voice_call`, `communication.voice_message`, `communication.play`, `communication.share_notes`, `communication.select_notebook`, `communication.shared_notes`, `communication.upload_file`, `communication.drag_drop`, `communication.teacher_notes`, `communication.private_notes`, `communication.save_note`, `communication.notes_saved`, etc.
|
||||||
|
|
||||||
|
## Task ID: 9-a - Practice Exercises + Enhanced Virtual Character Builder
|
||||||
|
|
||||||
|
**Date:** 2025-01-21
|
||||||
|
**Agent:** Practice Exercises + Enhanced Virtual Character Builder
|
||||||
|
**Status:** Completed
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Built two major features: (1) Practice Exercises with AI-generated multiple-choice questions and (2) Enhanced Virtual Character with animations, accessories, AI chat, and interactions.
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
1. `src/app/api/subject-contents/exercises/route.ts` - API route for generating practice exercises with AI (POST) and fetching existing questions (GET)
|
||||||
|
- Uses z-ai-web-dev-sdk to generate multiple-choice questions
|
||||||
|
- Rate limit: max 10 requests per day per user
|
||||||
|
- Saves generated questions to SubjectContent's `content` field as JSON
|
||||||
|
- Awards XP to virtual character for generating exercises
|
||||||
|
|
||||||
|
2. `src/app/api/subject-contents/exercises/` directory - Created for the exercises API
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. `src/lib/i18n.ts` - Added 100+ i18n keys for practice exercises and enhanced virtual character (DE + EN)
|
||||||
|
2. `src/components/subjects-view.tsx` - Enhanced with full practice exercise mode (real AI questions, score tracking, celebration, retry incorrect)
|
||||||
|
3. `src/components/virtual-character.tsx` - Complete rewrite with animations, accessories, AI chat, and interactions
|
||||||
|
4. `src/app/api/ai/character/route.ts` - Updated to support accessories field
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Practice Exercises**: AI-generated multiple-choice questions, score tracking, detailed breakdown, retry incorrect, confetti celebration on high scores
|
||||||
|
- **Enhanced Virtual Character**: 5 character types with accessories (hat, glasses, scarf, cape, wand), AI chat, mood animations, daily tips, level-up celebrations
|
||||||
|
- **100+ i18n keys** added in both DE and EN
|
||||||
Reference in new issue
Block a user