SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
8.8 KB · 184 lines tsx
Raw Blame History
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