376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { UserRole } from "@prisma/client";
|
|
import { requireUser } from "@/lib/auth/requireUser";
|
|
import { db } from "@/lib/prisma";
|
|
import { getActiveRecommendations, getMiniGameGrade } from "@/lib/recommendations";
|
|
|
|
type ProfilePrismaClient = {
|
|
miniGameAttempt: {
|
|
findMany: (args: object) => Promise<
|
|
{
|
|
miniGameId: string;
|
|
scorePercent: number;
|
|
miniGame: { id: string; title: string; slug: string };
|
|
}[]
|
|
>;
|
|
};
|
|
};
|
|
|
|
function getText(value: unknown): string {
|
|
if (!value) return "";
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.en === "string") return record.en;
|
|
if (typeof record.es === "string") return record.es;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
const levelLabel: Record<string, string> = {
|
|
BEGINNER: "Beginner",
|
|
INTERMEDIATE: "Intermediate",
|
|
ADVANCED: "Advanced",
|
|
EXPERT: "Expert",
|
|
};
|
|
|
|
export default async function ProfilePage() {
|
|
const user = await requireUser();
|
|
if (!user?.id) {
|
|
redirect("/auth/login?redirectTo=/profile");
|
|
}
|
|
|
|
const isTeacher = user.role === UserRole.TEACHER || user.role === UserRole.SUPER_ADMIN;
|
|
|
|
if (isTeacher) {
|
|
const authoredCourses = await db.course
|
|
.findMany({
|
|
where: { authorId: user.id },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
tags: true,
|
|
level: true,
|
|
status: true,
|
|
learningOutcomes: true,
|
|
updatedAt: true,
|
|
_count: {
|
|
select: {
|
|
modules: true,
|
|
enrollments: true,
|
|
},
|
|
},
|
|
modules: {
|
|
select: {
|
|
lessons: {
|
|
select: {
|
|
id: true,
|
|
videoUrl: true,
|
|
youtubeUrl: true,
|
|
isFreePreview: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to load authored courses for profile.", error);
|
|
return [];
|
|
});
|
|
|
|
const totalCourses = authoredCourses.length;
|
|
const publishedCourses = authoredCourses.filter((course) => course.status === "PUBLISHED").length;
|
|
const totalModules = authoredCourses.reduce((acc, course) => acc + course._count.modules, 0);
|
|
const totalStudents = authoredCourses.reduce((acc, course) => acc + course._count.enrollments, 0);
|
|
|
|
const lessons = authoredCourses.flatMap((course) => course.modules.flatMap((module) => module.lessons));
|
|
const totalLessons = lessons.length;
|
|
const uploadedLessons = lessons.filter((lesson) => lesson.videoUrl || lesson.youtubeUrl).length;
|
|
const previewLessons = lessons.filter((lesson) => lesson.isFreePreview).length;
|
|
|
|
const topicSet = new Set<string>();
|
|
const outcomeSet = new Set<string>();
|
|
for (const course of authoredCourses) {
|
|
for (const tag of course.tags) {
|
|
if (tag.trim()) topicSet.add(tag.trim());
|
|
}
|
|
if (Array.isArray(course.learningOutcomes)) {
|
|
for (const outcome of course.learningOutcomes) {
|
|
if (typeof outcome === "string" && outcome.trim()) {
|
|
outcomeSet.add(outcome.trim());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const masteredTopics = topicSet.size > 0 ? [...topicSet] : [...new Set(authoredCourses.map((course) => levelLabel[course.level] ?? course.level))];
|
|
const strengths = [...outcomeSet].slice(0, 8);
|
|
|
|
return (
|
|
<div className="acve-page">
|
|
<section className="acve-panel acve-section-base">
|
|
<p className="acve-pill mb-3 w-fit">Profile</p>
|
|
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">{user.fullName || user.email}</h1>
|
|
<p className="mt-2 text-base text-slate-600">Teacher account | {user.email}</p>
|
|
</section>
|
|
|
|
<section className="grid gap-4 md:grid-cols-4">
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Courses created</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalCourses}</p>
|
|
</article>
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Published</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{publishedCourses}</p>
|
|
</article>
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Students</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalStudents}</p>
|
|
</article>
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Content units</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalModules + totalLessons}</p>
|
|
<p className="mt-2 text-sm text-slate-600">{totalModules} modules + {totalLessons} lessons</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section className="grid gap-4 lg:grid-cols-2">
|
|
<article className="acve-panel p-5">
|
|
<h2 className="text-2xl font-semibold text-slate-900">Topics mastered</h2>
|
|
{masteredTopics.length === 0 ? (
|
|
<p className="mt-3 text-sm text-slate-600">No topic metadata yet. Add tags to your courses.</p>
|
|
) : (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{masteredTopics.map((topic) => (
|
|
<span key={topic} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-700">
|
|
{topic}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{strengths.length > 0 ? (
|
|
<ul className="mt-4 space-y-2 text-sm text-slate-700">
|
|
{strengths.map((item) => (
|
|
<li key={item} className="rounded-lg border border-slate-200 px-3 py-2">
|
|
{item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</article>
|
|
|
|
<article className="acve-panel p-5">
|
|
<h2 className="text-2xl font-semibold text-slate-900">Upload information</h2>
|
|
<ul className="mt-3 space-y-2 text-sm text-slate-700">
|
|
<li className="rounded-lg border border-slate-200 px-3 py-2">
|
|
Lessons with media: {uploadedLessons}/{totalLessons || 0}
|
|
</li>
|
|
<li className="rounded-lg border border-slate-200 px-3 py-2">Preview lessons enabled: {previewLessons}</li>
|
|
<li className="rounded-lg border border-slate-200 px-3 py-2">Lessons pending upload: {Math.max(totalLessons - uploadedLessons, 0)}</li>
|
|
</ul>
|
|
<Link className="mt-4 inline-flex text-sm font-semibold text-brand hover:underline" href="/teacher/uploads">
|
|
Open upload library
|
|
</Link>
|
|
</article>
|
|
</section>
|
|
|
|
<section className="acve-panel p-5">
|
|
<h2 className="text-2xl font-semibold text-slate-900">Recent course activity</h2>
|
|
{authoredCourses.length === 0 ? (
|
|
<p className="mt-3 text-sm text-slate-600">No courses yet. Start by creating a new course.</p>
|
|
) : (
|
|
<ul className="mt-3 space-y-2">
|
|
{authoredCourses.slice(0, 6).map((course) => (
|
|
<li key={course.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
|
<p className="font-semibold text-slate-900">{getText(course.title) || "Untitled course"}</p>
|
|
<p className="text-xs text-slate-500">
|
|
{course.status} | {course._count.modules} modules | {course._count.enrollments} students | updated{" "}
|
|
{new Date(course.updatedAt).toLocaleDateString()}
|
|
</p>
|
|
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/teacher/courses/${course.slug}/edit`}>
|
|
Open editor
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const [grade, recommendations, certificates] = await Promise.all([
|
|
getMiniGameGrade(user.id).catch((error) => {
|
|
console.error("Failed to load mini-game grade.", error);
|
|
return 0;
|
|
}),
|
|
getActiveRecommendations(user.id).catch((error) => {
|
|
console.error("Failed to load recommendations.", error);
|
|
return [];
|
|
}),
|
|
db.certificate
|
|
.findMany({
|
|
where: { userId: user.id },
|
|
select: {
|
|
id: true,
|
|
issuedAt: true,
|
|
metadataSnapshot: true,
|
|
course: {
|
|
select: {
|
|
title: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { issuedAt: "desc" },
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to load certificates for profile.", error);
|
|
return [];
|
|
}),
|
|
]);
|
|
|
|
let attempts: {
|
|
miniGameId: string;
|
|
scorePercent: number;
|
|
miniGame: { id: string; title: string; slug: string };
|
|
}[] = [];
|
|
try {
|
|
attempts = await (db as unknown as ProfilePrismaClient).miniGameAttempt.findMany({
|
|
where: { userId: user.id },
|
|
include: {
|
|
miniGame: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { completedAt: "desc" },
|
|
});
|
|
} catch {
|
|
attempts = [];
|
|
}
|
|
|
|
const latestByGame = new Map<string, number>();
|
|
const bestByGame = new Map<string, number>();
|
|
const titleByGame = new Map<string, string>();
|
|
for (const attempt of attempts) {
|
|
if (!latestByGame.has(attempt.miniGameId)) {
|
|
latestByGame.set(attempt.miniGameId, attempt.scorePercent);
|
|
}
|
|
const best = bestByGame.get(attempt.miniGameId) ?? 0;
|
|
if (attempt.scorePercent > best) {
|
|
bestByGame.set(attempt.miniGameId, attempt.scorePercent);
|
|
}
|
|
titleByGame.set(attempt.miniGameId, attempt.miniGame.title);
|
|
}
|
|
|
|
const gameRows = [...titleByGame.keys()].map((gameId) => ({
|
|
gameId,
|
|
title: titleByGame.get(gameId) ?? "Mini-game",
|
|
latest: latestByGame.get(gameId) ?? 0,
|
|
best: bestByGame.get(gameId) ?? 0,
|
|
}));
|
|
|
|
return (
|
|
<div className="acve-page">
|
|
<section className="acve-panel acve-section-base">
|
|
<p className="acve-pill mb-3 w-fit">Profile</p>
|
|
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">{user.fullName || user.email}</h1>
|
|
<p className="mt-2 text-base text-slate-600">{user.email}</p>
|
|
</section>
|
|
|
|
<section className="grid gap-4 md:grid-cols-3">
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Mini-game grade</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{grade}%</p>
|
|
<p className="mt-2 text-sm text-slate-600">Average of latest attempts across mini-games.</p>
|
|
</article>
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Attempts</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{attempts.length}</p>
|
|
<Link className="mt-2 inline-flex text-sm font-semibold text-brand hover:underline" href="/practice">
|
|
Play mini-games
|
|
</Link>
|
|
</article>
|
|
<article className="acve-panel p-5">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Certificates</p>
|
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{certificates.length}</p>
|
|
<Link className="mt-2 inline-flex text-sm font-semibold text-brand hover:underline" href="/my-courses">
|
|
View my courses
|
|
</Link>
|
|
</article>
|
|
</section>
|
|
|
|
<section className="grid gap-4 lg:grid-cols-2">
|
|
<article className="acve-panel p-5">
|
|
<h2 className="text-2xl font-semibold text-slate-900">Mini-game breakdown</h2>
|
|
{gameRows.length === 0 ? (
|
|
<p className="mt-3 text-sm text-slate-600">No attempts yet. Complete a mini-game to generate your grade.</p>
|
|
) : (
|
|
<ul className="mt-3 space-y-2">
|
|
{gameRows.map((row) => (
|
|
<li key={row.gameId} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
|
<span className="font-semibold text-slate-900">{row.title}</span> | latest: {row.latest}% | best: {row.best}%
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</article>
|
|
|
|
<article className="acve-panel p-5">
|
|
<h2 className="text-2xl font-semibold text-slate-900">Recommended next</h2>
|
|
{recommendations.length === 0 ? (
|
|
<p className="mt-3 text-sm text-slate-600">No recommendations yet. Complete a mini-game or enroll in a course first.</p>
|
|
) : (
|
|
<ul className="mt-3 space-y-2">
|
|
{recommendations.map((recommendation) => (
|
|
<li key={recommendation.courseId} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
|
<p className="font-semibold text-slate-900">{recommendation.title}</p>
|
|
<p className="text-xs text-slate-500">{recommendation.reason}</p>
|
|
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/courses/${recommendation.slug}`}>
|
|
Open course
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</article>
|
|
</section>
|
|
|
|
<section className="acve-panel p-5">
|
|
<h2 className="text-2xl font-semibold text-slate-900">Certificates</h2>
|
|
{certificates.length === 0 ? (
|
|
<p className="mt-3 text-sm text-slate-600">No certificates issued yet.</p>
|
|
) : (
|
|
<ul className="mt-3 space-y-2">
|
|
{certificates.map((certificate) => {
|
|
const certificateNumber =
|
|
(certificate as Record<string, unknown>).certificateNumber ??
|
|
(certificate.metadataSnapshot as Record<string, unknown> | null)?.certificateNumber ??
|
|
"N/A";
|
|
return (
|
|
<li key={certificate.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
|
<p className="font-semibold text-slate-900">{getText(certificate.course.title) || "Course"}</p>
|
|
<p className="text-xs text-slate-500">
|
|
Certificate #{String(certificateNumber)} | issued {new Date(certificate.issuedAt).toLocaleDateString()}
|
|
</p>
|
|
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/api/certificates/${certificate.id}/pdf`}>
|
|
Download PDF
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|