TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Hub d'apprentissage : annonces actives, carte par cours (progression, série,3// éléments dus) et « prochaines meilleures activités » recommandées.4import Link from "next/link";5import { useEffect, useState } from "react";6import {7 ArrowRight, BellRing, BookOpenCheck, Flame, GraduationCap, Layers, Megaphone, Pin,8} from "lucide-react";9import { PageHeader } from "@/components/app-shell";10import { Badge, Card, EmptyState, ProgressBar, Skeleton } from "@/components/ui";11import { Markdown } from "@/components/chat/markdown";12import { ErrorBanner, fetchJson, fmtDate } from "./shared";1314type Course = { code: string; title: string; color: string };15type Announcement = { id: number; title: string; body: string; course_code: string | null; pinned: number; created_at: string };16type Recommendation = { kind: string; label: string; reason: string; href: string; courseCode: string; priority: number };17type Overview = {18 globalScore: number;19 streak: number;20 practicedCount: number;21 concepts: unknown[];22 stats: { cardsDue: number; errorsToReview: number; quizAccuracy: number | null; studyMinutes: number };23 recommendations: Recommendation[];24};2526export function LearnHub({ courses, displayName }: { courses: Course[]; displayName: string }) {27 const [announcements, setAnnouncements] = useState<Announcement[] | null>(null);28 const [overviews, setOverviews] = useState<Record<string, Overview | null>>({});29 const [error, setError] = useState<string | null>(null);3031 useEffect(() => {32 fetchJson<{ announcements: Announcement[] }>("/api/announcements")33 .then((d) => setAnnouncements(d.announcements))34 .catch((e) => { setAnnouncements([]); setError(e instanceof Error ? e.message : "Erreur de chargement."); });35 for (const c of courses) {36 fetchJson<Overview>(`/api/learning/${c.code.toLowerCase()}/overview`)37 .then((d) => setOverviews((o) => ({ ...o, [c.code]: d })))38 .catch(() => setOverviews((o) => ({ ...o, [c.code]: null })));39 }40 }, [courses]);4142 const recommendations = courses43 .flatMap((c) => overviews[c.code]?.recommendations ?? [])44 .sort((a, b) => b.priority - a.priority)45 .slice(0, 5);46 const overviewsLoaded = courses.every((c) => c.code in overviews);4748 return (49 <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full">50 <PageHeader51 title="Apprendre"52 subtitle={`Bonjour ${displayName} — voici où concentrer vos efforts aujourd'hui.`}53 />5455 {error && <ErrorBanner message={error} className="mb-5" />}5657 {/* Annonces */}58 {announcements === null ? (59 <div className="space-y-2 mb-7">60 <Skeleton className="h-16 w-full" />61 </div>62 ) : announcements.length > 0 ? (63 <section className="space-y-2.5 mb-7" aria-label="Annonces">64 {announcements.map((a) => (65 <Card key={a.id} className="p-4 animate-fade-up">66 <div className="flex flex-wrap items-center gap-2 mb-1.5">67 <Megaphone size={15} className="text-brand-500 shrink-0" />68 <h2 className="font-semibold text-sm text-fg">{a.title}</h2>69 {!!a.pinned && <Badge tone="gold"><Pin size={10} /> Épinglée</Badge>}70 {a.course_code && <Badge tone="brand">{a.course_code}</Badge>}71 <span className="text-[11px] text-muted ml-auto">{fmtDate(a.created_at)}</span>72 </div>73 <div className="text-sm">74 <Markdown content={a.body} />75 </div>76 </Card>77 ))}78 </section>79 ) : null}8081 {/* Cours */}82 <section aria-label="Mes cours" className="mb-8">83 <h2 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Mes cours</h2>84 {courses.length === 0 ? (85 <EmptyState86 icon={<GraduationCap />}87 title="Aucun cours inscrit"88 description="Votre compte n'est associé à aucun cours actif. Contactez votre professeur pour l'inscription."89 />90 ) : (91 <div className="grid sm:grid-cols-2 gap-4">92 {courses.map((c) => {93 const ov = overviews[c.code];94 const loading = !(c.code in overviews);95 return (96 <Card key={c.code} className="p-5 animate-fade-up hover:border-brand-300 transition-colors">97 <Link href={`/apprendre/${c.code.toLowerCase()}`} className="block group">98 <div className="flex items-center gap-2.5 mb-1">99 <span className="w-3 h-3 rounded-full shrink-0" style={{ background: c.color }} aria-hidden />100 <h3 className="font-bold text-fg group-hover:text-brand-600 dark:group-hover:text-brand-300 transition-colors">101 {c.code}102 </h3>103 <ArrowRight size={15} className="text-muted ml-auto group-hover:translate-x-0.5 transition-transform" />104 </div>105 <p className="text-[13px] text-muted line-clamp-1 mb-4">{c.title}</p>106 </Link>107 {loading ? (108 <div className="space-y-2.5">109 <Skeleton className="h-2.5 w-full" />110 <Skeleton className="h-4 w-2/3" />111 </div>112 ) : ov ? (113 <>114 <div className="flex items-center justify-between text-[12px] mb-1.5">115 <span className="text-muted">Maîtrise globale estimée</span>116 <span className="font-semibold text-fg">{Math.round(ov.globalScore * 100)} %</span>117 </div>118 <ProgressBar value={ov.globalScore} tone={ov.globalScore >= 0.6 ? "green" : "brand"} />119 <div className="flex flex-wrap gap-1.5 mt-3.5">120 {ov.streak > 0 && (121 <Badge tone="gold"><Flame size={11} /> {ov.streak} jour{ov.streak > 1 ? "s" : ""}</Badge>122 )}123 <Badge tone={ov.stats.cardsDue > 0 ? "amber" : "neutral"}>124 <Layers size={11} /> {ov.stats.cardsDue} carte{ov.stats.cardsDue > 1 ? "s" : ""} due{ov.stats.cardsDue > 1 ? "s" : ""}125 </Badge>126 {ov.stats.errorsToReview > 0 && (127 <Badge tone="red">{ov.stats.errorsToReview} erreur{ov.stats.errorsToReview > 1 ? "s" : ""} à revoir</Badge>128 )}129 </div>130 </>131 ) : (132 <p className="text-[12.5px] text-muted">Progression indisponible pour le moment.</p>133 )}134 </Card>135 );136 })}137 </div>138 )}139 </section>140141 {/* Recommandations */}142 <section aria-label="Recommandations">143 <h2 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Recommandé pour vous</h2>144 {!overviewsLoaded ? (145 <div className="space-y-2">146 <Skeleton className="h-14 w-full" />147 <Skeleton className="h-14 w-full" />148 </div>149 ) : recommendations.length === 0 ? (150 <EmptyState151 icon={<BookOpenCheck />}152 title="Rien d'urgent"153 description="Commencez une activité — flashcards, quiz ou une question au chat — pour obtenir des recommandations personnalisées."154 />155 ) : (156 <div className="space-y-2.5">157 {recommendations.map((r, i) => (158 <Link key={`${r.courseCode}-${r.kind}-${i}`} href={r.href} className="block group">159 <Card className="p-4 flex items-start gap-3 hover:border-brand-300 transition-colors animate-fade-up">160 <div className="w-8 h-8 rounded-lg bg-brand-100 dark:bg-brand-900/60 text-brand-600 dark:text-brand-300 flex items-center justify-center shrink-0">161 <BellRing size={15} />162 </div>163 <div className="min-w-0 flex-1">164 <div className="flex items-center gap-2">165 <p className="text-sm font-semibold text-fg group-hover:text-brand-600 dark:group-hover:text-brand-300 transition-colors truncate">166 {r.label}167 </p>168 <Badge tone="brand" className="shrink-0">{r.courseCode}</Badge>169 </div>170 <p className="text-[12.5px] text-muted mt-0.5">171 <span className="font-medium">Pourquoi :</span> {r.reason}172 </p>173 </div>174 <ArrowRight size={15} className="text-muted shrink-0 mt-1 group-hover:translate-x-0.5 transition-transform" />175 </Card>176 </Link>177 ))}178 </div>179 )}180 </section>181 </main>182 );183}184