chore(retrait cluster): synchro copie du nœud M3U96b + README « URL live hors fonction » (2026-09-04)
153 changed files +20,001 −0
added
.gitignore
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +# Dépendances et build | |
| 2 | +node_modules/ | |
| 3 | +.next/ | |
| 4 | +out/ | |
| 5 | +*.tsbuildinfo | |
| 6 | +next-env.d.ts | |
| 7 | + | |
| 8 | +# Secrets — ne JAMAIS commiter de clés réelles | |
| 9 | +.env | |
| 10 | +.env.local | |
| 11 | +.env.production | |
| 12 | + | |
| 13 | +# Données locales (BD, uploads, cache de modèles d'embeddings) | |
| 14 | +data/ | |
| 15 | + | |
| 16 | +# Divers | |
| 17 | +.DS_Store | |
| 18 | +coverage/ | |
added
Dockerfile
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +# Immbot AI — image de production (Node 25 pour node:sqlite + type stripping natifs) | |
| 2 | +FROM node:25-slim AS deps | |
| 3 | +WORKDIR /app | |
| 4 | +RUN corepack enable | |
| 5 | +COPY package.json pnpm-lock.yaml* ./ | |
| 6 | +RUN pnpm install --frozen-lockfile || pnpm install | |
| 7 | + | |
| 8 | +FROM node:25-slim AS build | |
| 9 | +WORKDIR /app | |
| 10 | +RUN corepack enable | |
| 11 | +COPY --from=deps /app/node_modules ./node_modules | |
| 12 | +COPY . . | |
| 13 | +RUN pnpm build | |
| 14 | + | |
| 15 | +FROM node:25-slim AS run | |
| 16 | +WORKDIR /app | |
| 17 | +ENV NODE_ENV=production | |
| 18 | +RUN corepack enable | |
| 19 | +COPY --from=build /app ./ | |
| 20 | +EXPOSE 3070 | |
| 21 | +# Le seed et l'ingestion s'exécutent au premier démarrage si la base est vide. | |
| 22 | +CMD ["sh", "-c", "node --env-file=.env scripts/seed.ts && pnpm start"] | |
added
README.md
+64 −0
@@ -0,0 +1,64 @@ | ||
| 1 | +> ⚠️ **URL live hors fonction pour le moment.** L'application a été retirée du cluster MacLustr le 2026-09-04 (processus arrêtés, copie du nœud supprimée). Ce dépôt spbgit est désormais la seule source de vérité du projet. Ancienne URL : https://www.immbot.ai | |
| 2 | + | |
| 3 | +# Immbot AI | |
| 4 | + | |
| 5 | +**Environnement d'apprentissage intelligent pour les cours IMM1003 et IMM1033 (évaluation immobilière, UQO).** | |
| 6 | + | |
| 7 | +Immbot AI combine un assistant conversationnel multimodal fondé sur le **matériel officiel des cours** | |
| 8 | +(RAG avec citations vérifiables par diapositive), un **centre d'apprentissage** complet (flashcards à | |
| 9 | +répétition espacée, quiz adaptatifs, examens blancs, carte des concepts, plans d'étude, cahier | |
| 10 | +d'erreurs) et un **tableau de bord professeur** — propulsé par tous les modèles OpenRouter. | |
| 11 | + | |
| 12 | +## Démarrage rapide | |
| 13 | + | |
| 14 | +```bash | |
| 15 | +cd immbot-ai | |
| 16 | +./scripts/setup.sh # dépendances + .env + seed + ingestion des cours | |
| 17 | +./scripts/dev.sh # http://localhost:3070 | |
| 18 | +``` | |
| 19 | + | |
| 20 | +Connexion initiale : **admin / admin123** — le changement de mot de passe est **forcé** à la première | |
| 21 | +connexion (le mot de passe initial n'est jamais accepté comme permanent). | |
| 22 | + | |
| 23 | +Renseignez `OPENROUTER_API_KEY` dans `.env` pour activer le chat (le RAG, les flashcards seed, les | |
| 24 | +quiz et les examens blancs fonctionnent sans clé). | |
| 25 | + | |
| 26 | +## Points clés | |
| 27 | + | |
| 28 | +- **Source de vérité** : les fichiers LaTeX des cours (1 691 diapositives, plans, ateliers, glossaire) | |
| 29 | + sont parsés à la source → citations exactes `[IMM1003 — Séance 4 — Diapositive 18]`, cliquables, | |
| 30 | + **validées côté serveur** (aucune citation inventée ne survit). | |
| 31 | +- **Trois modes de connaissances** : Cours uniquement (défaut, refus honnête si le matériel ne suffit | |
| 32 | + pas) · Cours + général (sections séparées) · Général (signalé). | |
| 33 | +- **Dix modes pédagogiques** : demander au cours, tuteur, socratique, explique simplement, niveau | |
| 34 | + professionnel, corrige ma réponse, préparation d'examen, défi, analyse multimodale, révision ciblée. | |
| 35 | +- **Isolation stricte** : espaces `official-imm1003` / `official-imm1033` / téléversements étudiants / | |
| 36 | + `instructor-private` (examens et analyses, invisibles aux étudiants). Vérifiée par l'évaluation RAG. | |
| 37 | +- **Embeddings locaux** (multilingual-e5-small) : gratuits, privés, hors-ligne ; recherche hybride | |
| 38 | + BM25 + vectoriel + fusion RRF. Rappel mesuré : 24/24 sur les jeux de validation. | |
| 39 | +- **Coûts maîtrisés** : registre dynamique des modèles avec paliers, budgets quotidiens/mensuels par | |
| 40 | + étudiant et global, appliqués côté serveur. | |
| 41 | + | |
| 42 | +## Structure | |
| 43 | + | |
| 44 | +Voir `docs/technical-architecture.md`. Documentation complète dans `docs/` : | |
| 45 | +`setup` · `deployment` · `admin-guide` · `student-guide` · `openrouter` · `rag-architecture` · | |
| 46 | +`ingestion` · `security` (+ `security-model`) · `data-model` · `api` · `testing` · `troubleshooting`. | |
| 47 | + | |
| 48 | +## Commandes | |
| 49 | + | |
| 50 | +| Commande | Effet | | |
| 51 | +|---|---| | |
| 52 | +| `pnpm dev` / `pnpm build` / `pnpm start` | Développement / build / production (port 3070) | | |
| 53 | +| `pnpm seed` | Amorçage (admin, cours, prompts, contenu pédagogique) — idempotent | | |
| 54 | +| `pnpm ingest` / `pnpm reindex` | Ingestion incrémentale / complète du matériel de cours | | |
| 55 | +| `pnpm test` | Tests unitaires (vitest) | | |
| 56 | +| `pnpm evaluate` | Évaluation RAG (rappel, refus, isolation) → `evaluation/rag-evaluation.md` | | |
| 57 | +| `./scripts/verify.sh` | Types + tests + évaluation RAG | | |
| 58 | +| `./scripts/backup.sh` | Sauvegarde datée de la base | | |
| 59 | + | |
| 60 | +## Avertissement pédagogique | |
| 61 | + | |
| 62 | +Immbot AI aide à **apprendre**. La politique d'intégrité académique (configurable par le professeur) | |
| 63 | +privilégie les indices et la démarche pour les travaux notés, et la plateforme ne prétend jamais | |
| 64 | +prédire le contenu des examens réels. | |
added
app/(app)/admin/annonces/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminAnnouncements } from "@/components/admin/announcements"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Annonces — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminAnnouncementsPage() { | |
| 6 | + return <AdminAnnouncements />; | |
| 7 | +} | |
added
app/(app)/admin/cours/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminCourses } from "@/components/admin/courses"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Cours & contenu — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminCoursesPage() { | |
| 6 | + return <AdminCourses />; | |
| 7 | +} | |
added
app/(app)/admin/layout.tsx
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +// Layout serveur de l'administration : réservé aux professeurs et administrateurs. | |
| 2 | +import { redirect } from "next/navigation"; | |
| 3 | +import { currentUser } from "@/lib/auth/session.ts"; | |
| 4 | +import { AdminNav } from "@/components/admin/nav"; | |
| 5 | + | |
| 6 | +export const metadata = { title: "Administration — Immbot AI" }; | |
| 7 | + | |
| 8 | +export default async function AdminLayout({ children }: { children: React.ReactNode }) { | |
| 9 | + const user = await currentUser(); | |
| 10 | + if (!user) redirect("/connexion"); | |
| 11 | + if (user.role === "student") redirect("/chat"); | |
| 12 | + | |
| 13 | + return ( | |
| 14 | + <div className="flex min-h-0 flex-1 flex-col"> | |
| 15 | + <AdminNav /> | |
| 16 | + <main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 md:px-8">{children}</main> | |
| 17 | + </div> | |
| 18 | + ); | |
| 19 | +} | |
added
app/(app)/admin/modeles/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminModels } from "@/components/admin/models"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Modèles — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminModelsPage() { | |
| 6 | + return <AdminModels />; | |
| 7 | +} | |
added
app/(app)/admin/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminOverview } from "@/components/admin/overview"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Vue générale — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminOverviewPage() { | |
| 6 | + return <AdminOverview />; | |
| 7 | +} | |
added
app/(app)/admin/parametres/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminSettings } from "@/components/admin/settings"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Paramètres — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminSettingsPage() { | |
| 6 | + return <AdminSettings />; | |
| 7 | +} | |
added
app/(app)/admin/pedagogie/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminPedagogy } from "@/components/admin/pedagogy"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Pédagogie — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminPedagogyPage() { | |
| 6 | + return <AdminPedagogy />; | |
| 7 | +} | |
added
app/(app)/admin/prompts/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminPrompts } from "@/components/admin/prompts"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Prompts système — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminPromptsPage() { | |
| 6 | + return <AdminPrompts />; | |
| 7 | +} | |
added
app/(app)/admin/securite/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +import { AdminSecurity } from "@/components/admin/security"; | |
| 2 | + | |
| 3 | +export const metadata = { title: "Sécurité — Administration" }; | |
| 4 | + | |
| 5 | +export default function AdminSecurityPage() { | |
| 6 | + return <AdminSecurity />; | |
| 7 | +} | |
added
app/(app)/apprendre/[course]/concepts/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Carte interactive des concepts du cours (graphe SVG maison). | |
| 2 | +import { Suspense } from "react"; | |
| 3 | +import { ConceptMap } from "@/components/learning/concept-map"; | |
| 4 | + | |
| 5 | +export const metadata = { title: "Concepts" }; | |
| 6 | + | |
| 7 | +export default async function ConceptsPage({ params }: { params: Promise<{ course: string }> }) { | |
| 8 | + const { course } = await params; | |
| 9 | + return ( | |
| 10 | + <Suspense> | |
| 11 | + <ConceptMap course={course.toLowerCase()} /> | |
| 12 | + </Suspense> | |
| 13 | + ); | |
| 14 | +} | |
added
app/(app)/apprendre/[course]/diapositives/page.tsx
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +import { Suspense } from "react"; | |
| 2 | +import { SlidesViewer } from "@/components/learning/slides-viewer"; | |
| 3 | + | |
| 4 | +export const metadata = { title: "Diapositives" }; | |
| 5 | + | |
| 6 | +export default async function SlidesPage(ctx: { params: Promise<{ course: string }> }) { | |
| 7 | + const { course } = await ctx.params; | |
| 8 | + return ( | |
| 9 | + <Suspense> | |
| 10 | + <SlidesViewer course={course.toLowerCase()} /> | |
| 11 | + </Suspense> | |
| 12 | + ); | |
| 13 | +} | |
added
app/(app)/apprendre/[course]/erreurs/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +// Cahier d'erreurs du cours. | |
| 2 | +import { ErrorsApp } from "@/components/learning/errors-app"; | |
| 3 | + | |
| 4 | +export const metadata = { title: "Cahier d'erreurs" }; | |
| 5 | + | |
| 6 | +export default async function ErreursPage({ params }: { params: Promise<{ course: string }> }) { | |
| 7 | + const { course } = await params; | |
| 8 | + return <ErrorsApp course={course.toLowerCase()} />; | |
| 9 | +} | |
added
app/(app)/apprendre/[course]/examens/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +// Examens blancs : liste, tentative chronométrée ou pratique, résultats et analyse. | |
| 2 | +import { ExamsApp } from "@/components/learning/exams-app"; | |
| 3 | + | |
| 4 | +export const metadata = { title: "Examens" }; | |
| 5 | + | |
| 6 | +export default async function ExamensPage({ params }: { params: Promise<{ course: string }> }) { | |
| 7 | + const { course } = await params; | |
| 8 | + return <ExamsApp course={course.toLowerCase()} />; | |
| 9 | +} | |
added
app/(app)/apprendre/[course]/flashcards/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Révision des cartes mémoire (répétition espacée SM-2). | |
| 2 | +import { Suspense } from "react"; | |
| 3 | +import { FlashcardsApp } from "@/components/learning/flashcards-app"; | |
| 4 | + | |
| 5 | +export const metadata = { title: "Flashcards" }; | |
| 6 | + | |
| 7 | +export default async function FlashcardsPage({ params }: { params: Promise<{ course: string }> }) { | |
| 8 | + const { course } = await params; | |
| 9 | + return ( | |
| 10 | + <Suspense> | |
| 11 | + <FlashcardsApp course={course.toLowerCase()} /> | |
| 12 | + </Suspense> | |
| 13 | + ); | |
| 14 | +} | |
added
app/(app)/apprendre/[course]/layout.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +// Layout serveur des pages de cours : validation du cours, vérification de | |
| 2 | +// l'inscription, sous-navigation horizontale. | |
| 3 | +import { notFound, redirect } from "next/navigation"; | |
| 4 | +import { currentUser } from "@/lib/auth/session.ts"; | |
| 5 | +import { get } from "@/lib/db/index.ts"; | |
| 6 | +import { CourseNav } from "@/components/learning/course-nav"; | |
| 7 | + | |
| 8 | +export default async function CourseLayout({ | |
| 9 | + children, | |
| 10 | + params, | |
| 11 | +}: { | |
| 12 | + children: React.ReactNode; | |
| 13 | + params: Promise<{ course: string }>; | |
| 14 | +}) { | |
| 15 | + const { course } = await params; | |
| 16 | + const slug = course.toLowerCase(); | |
| 17 | + if (slug !== "imm1003" && slug !== "imm1033") notFound(); | |
| 18 | + const code = slug.toUpperCase(); | |
| 19 | + | |
| 20 | + const user = await currentUser(); | |
| 21 | + if (!user) redirect("/connexion"); | |
| 22 | + | |
| 23 | + const enrolled = get("SELECT 1 as ok FROM enrollments WHERE user_id = ? AND course_code = ?", user.id, code); | |
| 24 | + if (!enrolled) redirect("/apprendre"); | |
| 25 | + | |
| 26 | + const row = get<{ title: string; color: string }>("SELECT title, color FROM courses WHERE code = ?", code); | |
| 27 | + | |
| 28 | + return ( | |
| 29 | + <div className="flex-1 flex flex-col min-h-0"> | |
| 30 | + <CourseNav course={slug} title={row?.title ?? code} color={row?.color ?? "#1d64b0"} /> | |
| 31 | + <div className="flex-1">{children}</div> | |
| 32 | + </div> | |
| 33 | + ); | |
| 34 | +} | |
added
app/(app)/apprendre/[course]/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +// Tableau de progression du cours. | |
| 2 | +import { ProgressDashboard } from "@/components/learning/progress-dashboard"; | |
| 3 | + | |
| 4 | +export const metadata = { title: "Progression" }; | |
| 5 | + | |
| 6 | +export default async function ProgressionPage({ params }: { params: Promise<{ course: string }> }) { | |
| 7 | + const { course } = await params; | |
| 8 | + return <ProgressDashboard course={course.toLowerCase()} />; | |
| 9 | +} | |
added
app/(app)/apprendre/[course]/plan/page.tsx
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +// Plan d'étude personnalisé (création et suivi). | |
| 2 | +import { PlanApp } from "@/components/learning/plan-app"; | |
| 3 | + | |
| 4 | +export const metadata = { title: "Plan d'étude" }; | |
| 5 | + | |
| 6 | +export default async function PlanPage({ params }: { params: Promise<{ course: string }> }) { | |
| 7 | + const { course } = await params; | |
| 8 | + return <PlanApp course={course.toLowerCase()} />; | |
| 9 | +} | |
added
app/(app)/apprendre/[course]/quiz/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Quiz adaptatif (difficulté en escalier, ciblage des faiblesses). | |
| 2 | +import { Suspense } from "react"; | |
| 3 | +import { QuizApp } from "@/components/learning/quiz-app"; | |
| 4 | + | |
| 5 | +export const metadata = { title: "Quiz" }; | |
| 6 | + | |
| 7 | +export default async function QuizPage({ params }: { params: Promise<{ course: string }> }) { | |
| 8 | + const { course } = await params; | |
| 9 | + return ( | |
| 10 | + <Suspense> | |
| 11 | + <QuizApp course={course.toLowerCase()} /> | |
| 12 | + </Suspense> | |
| 13 | + ); | |
| 14 | +} | |
added
app/(app)/apprendre/[course]/resumes/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Résumés intelligents générés à partir du matériel officiel du cours. | |
| 2 | +import { Suspense } from "react"; | |
| 3 | +import { SummariesApp } from "@/components/learning/summaries-app"; | |
| 4 | + | |
| 5 | +export const metadata = { title: "Résumés" }; | |
| 6 | + | |
| 7 | +export default async function ResumesPage({ params }: { params: Promise<{ course: string }> }) { | |
| 8 | + const { course } = await params; | |
| 9 | + return ( | |
| 10 | + <Suspense> | |
| 11 | + <SummariesApp course={course.toLowerCase()} /> | |
| 12 | + </Suspense> | |
| 13 | + ); | |
| 14 | +} | |
added
app/(app)/apprendre/page.tsx
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +// Hub d'apprentissage : annonces, cartes des cours inscrits, recommandations. | |
| 2 | +import { requireUser } from "@/lib/auth/session.ts"; | |
| 3 | +import { all } from "@/lib/db/index.ts"; | |
| 4 | +import { LearnHub } from "@/components/learning/learn-hub"; | |
| 5 | + | |
| 6 | +export const metadata = { title: "Apprendre" }; | |
| 7 | + | |
| 8 | +export default async function ApprendrePage() { | |
| 9 | + const user = await requireUser(); | |
| 10 | + const courses = all<{ code: string; title: string; color: string }>( | |
| 11 | + `SELECT c.code, c.title, c.color FROM courses c JOIN enrollments e ON e.course_code = c.code | |
| 12 | + WHERE e.user_id = ? AND c.active = 1 ORDER BY c.code`, | |
| 13 | + user.id | |
| 14 | + ); | |
| 15 | + return <LearnHub courses={courses} displayName={user.display_name || user.username} />; | |
| 16 | +} | |
added
app/(app)/bibliotheque/page.tsx
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +// Bibliothèque personnelle : éléments sauvegardés depuis le chat et les outils. | |
| 2 | +import { LibraryApp } from "@/components/learning/library-app"; | |
| 3 | + | |
| 4 | +export const metadata = { title: "Bibliothèque" }; | |
| 5 | + | |
| 6 | +export default function BibliothequePage() { | |
| 7 | + return <LibraryApp />; | |
| 8 | +} | |
added
app/(app)/chat/[id]/page.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +import { Suspense } from "react"; | |
| 2 | +import { notFound } from "next/navigation"; | |
| 3 | +import { requireUser } from "@/lib/auth/session.ts"; | |
| 4 | +import { all, get } from "@/lib/db/index.ts"; | |
| 5 | +import { ChatApp } from "@/components/chat/chat-app"; | |
| 6 | + | |
| 7 | +export const metadata = { title: "Chat" }; | |
| 8 | + | |
| 9 | +export default async function ChatConversationPage(ctx: { params: Promise<{ id: string }> }) { | |
| 10 | + const user = await requireUser(); | |
| 11 | + const id = parseInt((await ctx.params).id, 10); | |
| 12 | + const conv = get("SELECT id FROM conversations WHERE id = ? AND user_id = ?", id, user.id); | |
| 13 | + if (!conv) notFound(); | |
| 14 | + const courses = all<{ code: string; title: string; color: string }>( | |
| 15 | + `SELECT c.code, c.title, c.color FROM courses c JOIN enrollments e ON e.course_code = c.code | |
| 16 | + WHERE e.user_id = ? AND c.active = 1 ORDER BY c.code`, | |
| 17 | + user.id | |
| 18 | + ); | |
| 19 | + return ( | |
| 20 | + <Suspense> | |
| 21 | + <ChatApp courses={courses} initialConversationId={id} /> | |
| 22 | + </Suspense> | |
| 23 | + ); | |
| 24 | +} | |
added
app/(app)/chat/page.tsx
+20 −0
@@ -0,0 +1,20 @@ | ||
| 1 | +import { Suspense } from "react"; | |
| 2 | +import { requireUser } from "@/lib/auth/session.ts"; | |
| 3 | +import { all } from "@/lib/db/index.ts"; | |
| 4 | +import { ChatApp } from "@/components/chat/chat-app"; | |
| 5 | + | |
| 6 | +export const metadata = { title: "Chat" }; | |
| 7 | + | |
| 8 | +export default async function ChatPage() { | |
| 9 | + const user = await requireUser(); | |
| 10 | + const courses = all<{ code: string; title: string; color: string }>( | |
| 11 | + `SELECT c.code, c.title, c.color FROM courses c JOIN enrollments e ON e.course_code = c.code | |
| 12 | + WHERE e.user_id = ? AND c.active = 1 ORDER BY c.code`, | |
| 13 | + user.id | |
| 14 | + ); | |
| 15 | + return ( | |
| 16 | + <Suspense> | |
| 17 | + <ChatApp courses={courses} /> | |
| 18 | + </Suspense> | |
| 19 | + ); | |
| 20 | +} | |
added
app/(app)/layout.tsx
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +import { redirect } from "next/navigation"; | |
| 2 | +import { currentUser } from "@/lib/auth/session.ts"; | |
| 3 | +import { all } from "@/lib/db/index.ts"; | |
| 4 | +import { AppShell } from "@/components/app-shell"; | |
| 5 | + | |
| 6 | +export default async function AppLayout({ children }: { children: React.ReactNode }) { | |
| 7 | + const user = await currentUser(); | |
| 8 | + if (!user) redirect("/connexion"); | |
| 9 | + if (user.must_change_password) redirect("/changer-mot-de-passe?force=1"); | |
| 10 | + | |
| 11 | + const courses = all<{ code: string; title: string; color: string }>( | |
| 12 | + `SELECT c.code, c.title, c.color FROM courses c | |
| 13 | + JOIN enrollments e ON e.course_code = c.code | |
| 14 | + WHERE e.user_id = ? AND c.active = 1 ORDER BY c.code`, | |
| 15 | + user.id | |
| 16 | + ); | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <AppShell | |
| 20 | + user={{ | |
| 21 | + id: user.id, | |
| 22 | + username: user.username, | |
| 23 | + displayName: user.display_name || user.username, | |
| 24 | + role: user.role, | |
| 25 | + isInitialAdmin: !!user.is_initial_admin, | |
| 26 | + }} | |
| 27 | + courses={courses} | |
| 28 | + > | |
| 29 | + {children} | |
| 30 | + </AppShell> | |
| 31 | + ); | |
| 32 | +} | |
added
app/(app)/parametres/page.tsx
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +// Paramètres : profil, mot de passe, thème, raccourcis, transparence. | |
| 2 | +import { requireUser } from "@/lib/auth/session.ts"; | |
| 3 | +import { SettingsApp } from "@/components/learning/settings-app"; | |
| 4 | + | |
| 5 | +export const metadata = { title: "Paramètres" }; | |
| 6 | + | |
| 7 | +export default async function ParametresPage() { | |
| 8 | + const user = await requireUser(); | |
| 9 | + return ( | |
| 10 | + <SettingsApp | |
| 11 | + displayName={user.display_name || user.username} | |
| 12 | + username={user.username} | |
| 13 | + email={user.email} | |
| 14 | + role={user.role} | |
| 15 | + /> | |
| 16 | + ); | |
| 17 | +} | |
added
app/(public)/changer-mot-de-passe/page.tsx
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +"use client"; | |
| 2 | +import { useRouter, useSearchParams } from "next/navigation"; | |
| 3 | +import { Suspense, useState } from "react"; | |
| 4 | +import { ImmbotLogo, UqoLogo } from "@/components/logo"; | |
| 5 | +import { Button, Card, Input, Label, Spinner } from "@/components/ui"; | |
| 6 | + | |
| 7 | +function ChangePasswordForm() { | |
| 8 | + const router = useRouter(); | |
| 9 | + const forced = useSearchParams().get("force") === "1"; | |
| 10 | + const [current, setCurrent] = useState(""); | |
| 11 | + const [next, setNext] = useState(""); | |
| 12 | + const [confirm, setConfirm] = useState(""); | |
| 13 | + const [error, setError] = useState<string | null>(null); | |
| 14 | + const [loading, setLoading] = useState(false); | |
| 15 | + | |
| 16 | + async function submit(e: React.FormEvent) { | |
| 17 | + e.preventDefault(); | |
| 18 | + setError(null); | |
| 19 | + if (next !== confirm) return setError("La confirmation ne correspond pas."); | |
| 20 | + setLoading(true); | |
| 21 | + try { | |
| 22 | + const res = await fetch("/api/auth/change-password", { | |
| 23 | + method: "POST", | |
| 24 | + headers: { "Content-Type": "application/json" }, | |
| 25 | + body: JSON.stringify({ currentPassword: current, newPassword: next }), | |
| 26 | + }); | |
| 27 | + const data = await res.json(); | |
| 28 | + if (!res.ok) return setError(data.error ?? "Erreur."); | |
| 29 | + router.push("/chat"); | |
| 30 | + router.refresh(); | |
| 31 | + } catch { | |
| 32 | + setError("Impossible de joindre le serveur."); | |
| 33 | + } finally { | |
| 34 | + setLoading(false); | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <Card className="w-full max-w-sm p-7 animate-fade-up"> | |
| 40 | + <h1 className="text-lg font-bold text-fg">Changer le mot de passe</h1> | |
| 41 | + {forced ? ( | |
| 42 | + <p className="text-sm mt-2 text-amber-700 dark:text-amber-400 bg-amber-500/10 rounded-lg px-3 py-2.5"> | |
| 43 | + <strong>Sécurité :</strong> le mot de passe initial doit être remplacé avant d'utiliser la | |
| 44 | + plateforme. Choisissez un mot de passe fort et unique. | |
| 45 | + </p> | |
| 46 | + ) : ( | |
| 47 | + <p className="text-sm text-muted mt-1">Choisissez un nouveau mot de passe (10 caractères minimum).</p> | |
| 48 | + )} | |
| 49 | + <form onSubmit={submit} className="mt-6 space-y-4"> | |
| 50 | + <div> | |
| 51 | + <Label htmlFor="current">Mot de passe actuel</Label> | |
| 52 | + <Input id="current" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" required autoFocus /> | |
| 53 | + </div> | |
| 54 | + <div> | |
| 55 | + <Label htmlFor="next">Nouveau mot de passe</Label> | |
| 56 | + <Input id="next" type="password" value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" required minLength={10} /> | |
| 57 | + </div> | |
| 58 | + <div> | |
| 59 | + <Label htmlFor="confirm">Confirmer le nouveau mot de passe</Label> | |
| 60 | + <Input id="confirm" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required minLength={10} /> | |
| 61 | + </div> | |
| 62 | + {error && <p className="text-sm text-red-600 dark:text-red-400 bg-red-500/10 rounded-lg px-3 py-2" role="alert">{error}</p>} | |
| 63 | + <Button type="submit" className="w-full justify-center" disabled={loading}> | |
| 64 | + {loading ? <Spinner /> : "Enregistrer et continuer"} | |
| 65 | + </Button> | |
| 66 | + </form> | |
| 67 | + </Card> | |
| 68 | + ); | |
| 69 | +} | |
| 70 | + | |
| 71 | +export default function ChangePasswordPage() { | |
| 72 | + return ( | |
| 73 | + <div className="min-h-dvh bg-app flex flex-col items-center justify-center px-4"> | |
| 74 | + <div className="mb-8"><ImmbotLogo size={34} /></div> | |
| 75 | + <Suspense> | |
| 76 | + <ChangePasswordForm /> | |
| 77 | + </Suspense> | |
| 78 | + <div className="mt-8"><UqoLogo height={28} className="opacity-80 dark:invert" /></div> | |
| 79 | + </div> | |
| 80 | + ); | |
| 81 | +} | |
added
app/(public)/conditions/page.tsx
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +// Conditions d'utilisation — cadre académique (UQO). | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { ImmbotLogo, UqoLogo } from "@/components/logo"; | |
| 4 | + | |
| 5 | +export const metadata = { title: "Conditions d'utilisation" }; | |
| 6 | + | |
| 7 | +const UPDATED = "4 août 2026"; | |
| 8 | + | |
| 9 | +export default function ConditionsPage() { | |
| 10 | + return ( | |
| 11 | + <div className="min-h-dvh bg-app"> | |
| 12 | + <div className="h-1 bg-brand-700" aria-hidden><div className="h-full w-24 bg-gold-500 ml-4 sm:ml-8" /></div> | |
| 13 | + <header className="bg-card border-b border-app"> | |
| 14 | + <div className="max-w-3xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between"> | |
| 15 | + <Link href="/"><ImmbotLogo size={26} /></Link> | |
| 16 | + <UqoLogo height={28} className="dark:invert" /> | |
| 17 | + </div> | |
| 18 | + </header> | |
| 19 | + | |
| 20 | + <main className="max-w-3xl mx-auto px-4 sm:px-6 py-10 sm:py-14"> | |
| 21 | + <p className="text-[12px] font-bold uppercase tracking-widest text-gold-600 dark:text-gold-400">Document d'encadrement</p> | |
| 22 | + <h1 className="text-2xl sm:text-4xl font-bold tracking-tight text-fg mt-2">Conditions d'utilisation</h1> | |
| 23 | + <p className="text-sm text-muted mt-2"> | |
| 24 | + Plateforme pédagogique Immbot AI — cours IMM1003 et IMM1033, Université du Québec en Outaouais. | |
| 25 | + Dernière mise à jour : {UPDATED}. | |
| 26 | + </p> | |
| 27 | + | |
| 28 | + <div className="prose-immbot mt-8 space-y-6 text-[14.5px] [&_h2]:text-lg [&_h2]:font-bold [&_h2]:text-fg [&_h2]:mt-8 [&_h2]:tracking-tight [&_p]:text-fg [&_li]:text-fg"> | |
| 29 | + <section> | |
| 30 | + <h2>1. Nature et finalité de la plateforme</h2> | |
| 31 | + <p> | |
| 32 | + Immbot AI est un <strong>outil pédagogique complémentaire</strong> mis en place par le professeur | |
| 33 | + Simon-Pierre Boucher (Département des sciences administratives, UQO) pour soutenir l'apprentissage | |
| 34 | + dans les cours IMM1003 — Éléments d'évaluation immobilière et IMM1033 — Méthodes du coût en | |
| 35 | + évaluation immobilière. Elle est offerte <strong>gratuitement</strong>, sans finalité commerciale, | |
| 36 | + publicitaire ni de revente de données. | |
| 37 | + </p> | |
| 38 | + <p> | |
| 39 | + La plateforme ne remplace ni les séances, ni le matériel officiel, ni le plan de cours. En cas de | |
| 40 | + divergence entre une réponse de l'assistant et le plan de cours, le matériel officiel ou les | |
| 41 | + directives du professeur, <strong>ces derniers prévalent toujours</strong>. | |
| 42 | + </p> | |
| 43 | + </section> | |
| 44 | + | |
| 45 | + <section> | |
| 46 | + <h2>2. Admissibilité et compte</h2> | |
| 47 | + <ul> | |
| 48 | + <li>L'accès est réservé aux personnes inscrites à IMM1003 ou IMM1033 (session Automne 2026), au personnel enseignant concerné et, à des fins d'essai, au compte de démonstration partagé.</li> | |
| 49 | + <li>Vous êtes responsable de la confidentialité de votre mot de passe et des activités menées avec votre compte. Le partage de compte est interdit (le compte de démonstration, explicitement partagé, fait exception).</li> | |
| 50 | + <li>Le professeur peut suspendre un compte en cas d'usage abusif, de contournement des mesures de sécurité ou de violation des présentes conditions.</li> | |
| 51 | + </ul> | |
| 52 | + </section> | |
| 53 | + | |
| 54 | + <section> | |
| 55 | + <h2>3. Propriété intellectuelle du matériel de cours</h2> | |
| 56 | + <p> | |
| 57 | + Le matériel indexé par la plateforme (diapositives, plans de cours, ateliers, aide-mémoire, | |
| 58 | + glossaire) est protégé par le droit d'auteur et demeure la propriété de son auteur, conformément à | |
| 59 | + la <em>Politique et règles en matière de gestion de la propriété intellectuelle</em> de l'UQO. | |
| 60 | + L'accès qui vous y est donné est strictement limité à <strong>votre usage personnel d'étude</strong> dans | |
| 61 | + le cadre du cours. Il est interdit de : | |
| 62 | + </p> | |
| 63 | + <ul> | |
| 64 | + <li>rediffuser, publier ou vendre le matériel de cours ou les contenus générés qui le reproduisent en substance (y compris sur des sites de partage de notes) ;</li> | |
| 65 | + <li>procéder à une extraction massive ou automatisée du contenu (moissonnage, scraping) ;</li> | |
| 66 | + <li>utiliser le matériel pour entraîner des systèmes tiers.</li> | |
| 67 | + </ul> | |
| 68 | + </section> | |
| 69 | + | |
| 70 | + <section> | |
| 71 | + <h2>4. Intégrité académique</h2> | |
| 72 | + <p> | |
| 73 | + L'utilisation de la plateforme est encadrée par le{" "} | |
| 74 | + <a href="https://uqo.ca/sites/default/files/fichiers-uqo/plagiat.pdf" target="_blank" rel="noopener noreferrer"> | |
| 75 | + Règlement concernant le plagiat et la fraude | |
| 76 | + </a>{" "} | |
| 77 | + de l'UQO et par les balises du réseau de l'Université du Québec sur l'utilisation responsable de | |
| 78 | + l'intelligence artificielle générative. En particulier : | |
| 79 | + </p> | |
| 80 | + <ul> | |
| 81 | + <li>Les usages permis ou interdits de l'IA pour chaque évaluation sont ceux précisés dans le <strong>plan de cours</strong> et les consignes de chaque travail — ils prévalent sur tout comportement de la plateforme.</li> | |
| 82 | + <li>Immbot AI est conçu pour vous faire <strong>apprendre</strong> : pour les travaux notés, il privilégie les indices et la démarche plutôt que des réponses finales à remettre. Contourner délibérément ces garde-fous pour produire un travail à remettre peut constituer un manquement au Règlement.</li> | |
| 83 | + <li>Remettre comme sien un contenu généré par l'IA, lorsque cela n'est pas autorisé, constitue du plagiat au sens du Règlement et expose aux sanctions qui y sont prévues (voir <a href="https://uqo.ca/integrite" target="_blank" rel="noopener noreferrer">uqo.ca/integrite</a>).</li> | |
| 84 | + </ul> | |
| 85 | + </section> | |
| 86 | + | |
| 87 | + <section> | |
| 88 | + <h2>5. Limites de l'assistant d'intelligence artificielle</h2> | |
| 89 | + <ul> | |
| 90 | + <li>Les réponses sont générées par des modèles de langage et <strong>peuvent contenir des erreurs</strong>, même lorsqu'elles citent le matériel officiel. Vérifiez les citations (cliquables) et, au besoin, le document original.</li> | |
| 91 | + <li>Les indicateurs de « maîtrise estimée » sont des estimations pédagogiques — ils ne constituent ni une note, ni une prédiction de résultat, ni une évaluation officielle.</li> | |
| 92 | + <li>Les examens blancs sont des contenus originaux d'entraînement : ils ne prédisent pas le contenu des évaluations réelles.</li> | |
| 93 | + <li>Les analyses de photos ou de documents téléversés sont indicatives et ne constituent jamais une expertise professionnelle (aucune conclusion sur des vices cachés, la conformité réglementaire, etc.).</li> | |
| 94 | + </ul> | |
| 95 | + </section> | |
| 96 | + | |
| 97 | + <section> | |
| 98 | + <h2>6. Usage acceptable</h2> | |
| 99 | + <p>Il est notamment interdit d'utiliser la plateforme pour :</p> | |
| 100 | + <ul> | |
| 101 | + <li>tenter d'accéder aux données d'autres personnes, aux espaces réservés au professeur ou de contourner les contrôles d'accès ;</li> | |
| 102 | + <li>soumettre des contenus illicites, haineux ou portant atteinte aux droits d'autrui ;</li> | |
| 103 | + <li>un usage intensif manifestement étranger aux fins pédagogiques (les plafonds d'utilisation servent à garantir l'accès équitable de toutes et tous).</li> | |
| 104 | + </ul> | |
| 105 | + </section> | |
| 106 | + | |
| 107 | + <section> | |
| 108 | + <h2>7. Disponibilité et absence de garantie</h2> | |
| 109 | + <p> | |
| 110 | + La plateforme est un outil expérimental fourni « tel quel », sans garantie de disponibilité | |
| 111 | + continue ni d'exactitude. Elle peut être modifiée, suspendue ou retirée en tout temps, notamment en | |
| 112 | + période d'examen. Aucune décision académique ne repose sur la disponibilité de l'outil. | |
| 113 | + </p> | |
| 114 | + </section> | |
| 115 | + | |
| 116 | + <section> | |
| 117 | + <h2>8. Données personnelles</h2> | |
| 118 | + <p> | |
| 119 | + Le traitement des renseignements personnels est décrit dans la{" "} | |
| 120 | + <Link href="/confidentialite">Politique de confidentialité</Link>, établie dans l'esprit de la Loi | |
| 121 | + 25 et de la <em>Politique concernant l'accès aux documents et la protection des renseignements | |
| 122 | + personnels</em> de l'UQO. | |
| 123 | + </p> | |
| 124 | + </section> | |
| 125 | + | |
| 126 | + <section> | |
| 127 | + <h2>9. Modifications et contact</h2> | |
| 128 | + <p> | |
| 129 | + Les présentes conditions peuvent être mises à jour ; la date de mise à jour figure en tête du | |
| 130 | + document et les changements substantiels sont annoncés sur la plateforme. Questions :{" "} | |
| 131 | + <a href="mailto:simon-pierre.boucher@uqo.ca">simon-pierre.boucher@uqo.ca</a>. Les présentes sont | |
| 132 | + régies par le droit applicable au Québec. | |
| 133 | + </p> | |
| 134 | + </section> | |
| 135 | + </div> | |
| 136 | + | |
| 137 | + <div className="mt-10 flex flex-wrap gap-3"> | |
| 138 | + <Link href="/confidentialite" className="text-brand-600 dark:text-brand-300 font-semibold text-sm hover:underline"> | |
| 139 | + → Politique de confidentialité | |
| 140 | + </Link> | |
| 141 | + <Link href="/" className="text-muted font-medium text-sm hover:underline">Retour à l'accueil</Link> | |
| 142 | + </div> | |
| 143 | + </main> | |
| 144 | + </div> | |
| 145 | + ); | |
| 146 | +} | |
added
app/(public)/confidentialite/page.tsx
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +// Politique de confidentialité — établie dans l'esprit de la Loi 25 (Québec) | |
| 2 | +// et de la Politique d'accès aux documents et de protection des renseignements | |
| 3 | +// personnels de l'UQO. Plateforme auto-hébergée par le professeur. | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { ImmbotLogo, UqoLogo } from "@/components/logo"; | |
| 6 | + | |
| 7 | +export const metadata = { title: "Politique de confidentialité" }; | |
| 8 | + | |
| 9 | +const UPDATED = "4 août 2026"; | |
| 10 | + | |
| 11 | +export default function ConfidentialitePage() { | |
| 12 | + return ( | |
| 13 | + <div className="min-h-dvh bg-app"> | |
| 14 | + <div className="h-1 bg-brand-700" aria-hidden><div className="h-full w-24 bg-gold-500 ml-4 sm:ml-8" /></div> | |
| 15 | + <header className="bg-card border-b border-app"> | |
| 16 | + <div className="max-w-3xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between"> | |
| 17 | + <Link href="/"><ImmbotLogo size={26} /></Link> | |
| 18 | + <UqoLogo height={28} className="dark:invert" /> | |
| 19 | + </div> | |
| 20 | + </header> | |
| 21 | + | |
| 22 | + <main className="max-w-3xl mx-auto px-4 sm:px-6 py-10 sm:py-14"> | |
| 23 | + <p className="text-[12px] font-bold uppercase tracking-widest text-gold-600 dark:text-gold-400">Document d'encadrement</p> | |
| 24 | + <h1 className="text-2xl sm:text-4xl font-bold tracking-tight text-fg mt-2">Politique de confidentialité</h1> | |
| 25 | + <p className="text-sm text-muted mt-2"> | |
| 26 | + Plateforme pédagogique Immbot AI — IMM1003 · IMM1033, UQO. Dernière mise à jour : {UPDATED}. | |
| 27 | + Établie dans l'esprit de la Loi 25 (<em>Loi modernisant des dispositions législatives en matière de | |
| 28 | + protection des renseignements personnels</em>) et de la{" "} | |
| 29 | + <a className="underline" href="https://uqo.ca/sites/default/files/fichiers/20486-politique-concernant-lacces-aux-documents-la-protection-renseignements-personnels.pdf" target="_blank" rel="noopener noreferrer"> | |
| 30 | + Politique de l'UQO sur l'accès aux documents et la protection des renseignements personnels | |
| 31 | + </a>. | |
| 32 | + </p> | |
| 33 | + | |
| 34 | + <div className="prose-immbot mt-8 space-y-6 text-[14.5px] [&_h2]:text-lg [&_h2]:font-bold [&_h2]:text-fg [&_h2]:mt-8 [&_h2]:tracking-tight [&_p]:text-fg [&_li]:text-fg [&_td]:text-fg [&_th]:text-fg"> | |
| 35 | + <section> | |
| 36 | + <h2>1. Responsable et hébergement</h2> | |
| 37 | + <p> | |
| 38 | + La plateforme est <strong>hébergée de façon autonome par le professeur Simon-Pierre Boucher sur un | |
| 39 | + serveur situé au Québec</strong>, sous son contrôle direct. Il agit comme responsable de la | |
| 40 | + protection des renseignements personnels pour cet outil pédagogique :{" "} | |
| 41 | + <a href="mailto:simon-pierre.boucher@uqo.ca">simon-pierre.boucher@uqo.ca</a>. Aucune donnée n'est | |
| 42 | + hébergée chez un fournisseur infonuagique tiers ; seul un tunnel sécurisé (HTTPS) expose la | |
| 43 | + plateforme sur Internet. | |
| 44 | + </p> | |
| 45 | + </section> | |
| 46 | + | |
| 47 | + <section> | |
| 48 | + <h2>2. Renseignements recueillis</h2> | |
| 49 | + <ul> | |
| 50 | + <li><strong>Compte</strong> : identifiant choisi, nom affiché, courriel (facultatif), mot de passe (stocké uniquement sous forme hachée — jamais lisible).</li> | |
| 51 | + <li><strong>Activité pédagogique</strong> : conversations avec l'assistant, fichiers téléversés, révisions de cartes, réponses aux quiz et examens blancs, progression estimée, plans d'étude.</li> | |
| 52 | + <li><strong>Journaux techniques</strong> : évènements de connexion (date, adresse IP, navigateur) et journal d'utilisation des modèles (jetons, coûts, latence) — à des fins de sécurité et de gestion des plafonds.</li> | |
| 53 | + </ul> | |
| 54 | + <p> | |
| 55 | + Principe de <strong>minimisation</strong> : aucun renseignement au-delà de ce qui est nécessaire au | |
| 56 | + fonctionnement pédagogique n'est demandé (pas de code permanent, pas de date de naissance, pas de | |
| 57 | + données de paiement). | |
| 58 | + </p> | |
| 59 | + </section> | |
| 60 | + | |
| 61 | + <section> | |
| 62 | + <h2>3. Finalités</h2> | |
| 63 | + <ul> | |
| 64 | + <li>Fournir l'assistant, la révision espacée, les quiz, les examens blancs et le suivi de progression <strong>personnels</strong> ;</li> | |
| 65 | + <li>assurer la sécurité de la plateforme et le partage équitable des ressources (plafonds d'utilisation) ;</li> | |
| 66 | + <li>améliorer l'enseignement au moyen de <strong>statistiques agrégées et anonymisées</strong> (voir §5).</li> | |
| 67 | + </ul> | |
| 68 | + <p>Aucune donnée n'est vendue, louée, ni utilisée à des fins publicitaires ou commerciales.</p> | |
| 69 | + </section> | |
| 70 | + | |
| 71 | + <section> | |
| 72 | + <h2>4. Communication à des tiers (sous-traitants techniques)</h2> | |
| 73 | + <p> | |
| 74 | + Pour générer les réponses, le <strong>contenu de vos messages</strong> (et, le cas échéant, des | |
| 75 | + extraits du matériel de cours ou de vos fichiers joints) est transmis aux services suivants, | |
| 76 | + lesquels peuvent traiter ces données <strong>à l'extérieur du Québec</strong> : | |
| 77 | + </p> | |
| 78 | + <ul> | |
| 79 | + <li><strong>OpenRouter</strong> (et les fournisseurs de modèles qu'il relaie — Anthropic, OpenAI, Google, etc.) : génération des réponses de l'assistant ;</li> | |
| 80 | + <li><strong>Exa</strong> : recherche Web, lorsque le mode choisi le permet (jamais en mode « Cours uniquement ») ;</li> | |
| 81 | + <li><strong>Firecrawl</strong> : lecture de pages Web publiques, dans les mêmes conditions ;</li> | |
| 82 | + <li><strong>ngrok</strong> : acheminement chiffré (TLS) du trafic vers le serveur — sans conservation du contenu.</li> | |
| 83 | + </ul> | |
| 84 | + <p> | |
| 85 | + Ces transmissions se limitent au contenu nécessaire à la requête. Votre identifiant, votre courriel | |
| 86 | + et votre progression ne leur sont <strong>pas</strong> communiqués. En utilisant la plateforme, vous | |
| 87 | + consentez à cette communication ; si vous préférez l'éviter pour un échange donné, n'y insérez pas | |
| 88 | + de renseignements personnels. | |
| 89 | + </p> | |
| 90 | + </section> | |
| 91 | + | |
| 92 | + <section> | |
| 93 | + <h2>5. Ce que le professeur voit — et ne voit pas</h2> | |
| 94 | + <ul> | |
| 95 | + <li>Le professeur <strong>ne consulte pas</strong> vos conversations ni votre progression individuelle.</li> | |
| 96 | + <li>Il ne voit que des <strong>statistiques agrégées et anonymisées</strong> (notions difficiles, taux de réussite par question), affichées uniquement lorsqu'au moins trois étudiant·e·s sont concernés.</li> | |
| 97 | + <li>Exception : un message que <strong>vous signalez</strong> vous-même (bouton drapeau) lui est transmis avec son contexte immédiat pour correction du contenu.</li> | |
| 98 | + <li>Le compte de démonstration est <strong>partagé</strong> : n'y saisissez aucun renseignement personnel.</li> | |
| 99 | + </ul> | |
| 100 | + </section> | |
| 101 | + | |
| 102 | + <section> | |
| 103 | + <h2>6. Témoins (cookies) et traçage</h2> | |
| 104 | + <p> | |
| 105 | + La plateforme utilise <strong>un seul témoin</strong>, strictement nécessaire : le témoin de session | |
| 106 | + (httpOnly, sécurisé), qui vous maintient connecté·e. Aucun témoin publicitaire, aucun traceur tiers, | |
| 107 | + aucune mesure d'audience externe. | |
| 108 | + </p> | |
| 109 | + </section> | |
| 110 | + | |
| 111 | + <section> | |
| 112 | + <h2>7. Conservation et destruction</h2> | |
| 113 | + <ul> | |
| 114 | + <li>Les données de compte et d'apprentissage sont conservées pendant la session universitaire et au plus <strong>12 mois</strong> après la fin du cours, puis supprimées ou anonymisées.</li> | |
| 115 | + <li>Les journaux techniques sont conservés au plus 12 mois.</li> | |
| 116 | + <li>Vous pouvez demander la suppression de votre compte en tout temps (voir §9).</li> | |
| 117 | + </ul> | |
| 118 | + </section> | |
| 119 | + | |
| 120 | + <section> | |
| 121 | + <h2>8. Mesures de sécurité</h2> | |
| 122 | + <ul> | |
| 123 | + <li>Mots de passe hachés (bcrypt) ; jetons de session hachés en base ; témoin httpOnly sécurisé ;</li> | |
| 124 | + <li>cloisonnement strict des espaces (vos fichiers et votre progression ne sont accessibles qu'à vous) ;</li> | |
| 125 | + <li>chiffrement TLS de bout en bout, limitation des tentatives de connexion, journalisation de sécurité ;</li> | |
| 126 | + <li>en cas d'incident de confidentialité présentant un risque de préjudice sérieux, les personnes concernées et, le cas échéant, la Commission d'accès à l'information seront avisées, conformément à la Loi 25.</li> | |
| 127 | + </ul> | |
| 128 | + </section> | |
| 129 | + | |
| 130 | + <section> | |
| 131 | + <h2>9. Vos droits</h2> | |
| 132 | + <p> | |
| 133 | + Conformément à la Loi 25, vous pouvez demander l'<strong>accès</strong> à vos renseignements, leur{" "} | |
| 134 | + <strong>rectification</strong> ou la <strong>suppression de votre compte</strong> en écrivant à{" "} | |
| 135 | + <a href="mailto:simon-pierre.boucher@uqo.ca">simon-pierre.boucher@uqo.ca</a>. Les demandes sont | |
| 136 | + traitées dans un délai maximal de 30 jours. Vous pouvez également exporter vous-même vos | |
| 137 | + conversations (bouton Exporter) et vos contenus sauvegardés. | |
| 138 | + </p> | |
| 139 | + </section> | |
| 140 | + | |
| 141 | + <section> | |
| 142 | + <h2>10. Modifications</h2> | |
| 143 | + <p> | |
| 144 | + Toute modification substantielle de la présente politique sera annoncée sur la plateforme, avec mise | |
| 145 | + à jour de la date en tête du document. | |
| 146 | + </p> | |
| 147 | + </section> | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + <div className="mt-10 flex flex-wrap gap-3"> | |
| 151 | + <Link href="/conditions" className="text-brand-600 dark:text-brand-300 font-semibold text-sm hover:underline"> | |
| 152 | + → Conditions d'utilisation | |
| 153 | + </Link> | |
| 154 | + <Link href="/" className="text-muted font-medium text-sm hover:underline">Retour à l'accueil</Link> | |
| 155 | + </div> | |
| 156 | + </main> | |
| 157 | + </div> | |
| 158 | + ); | |
| 159 | +} | |
added
app/(public)/connexion/page.tsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +"use client"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { useRouter } from "next/navigation"; | |
| 4 | +import { useState } from "react"; | |
| 5 | +import { ImmbotLogo, UqoLogo } from "@/components/logo"; | |
| 6 | +import { Button, Card, Input, Label, Spinner } from "@/components/ui"; | |
| 7 | + | |
| 8 | +export default function LoginPage() { | |
| 9 | + const router = useRouter(); | |
| 10 | + const [username, setUsername] = useState(""); | |
| 11 | + const [password, setPassword] = useState(""); | |
| 12 | + const [error, setError] = useState<string | null>(null); | |
| 13 | + const [loading, setLoading] = useState(false); | |
| 14 | + | |
| 15 | + async function submit(e: React.FormEvent) { | |
| 16 | + e.preventDefault(); | |
| 17 | + setError(null); | |
| 18 | + setLoading(true); | |
| 19 | + try { | |
| 20 | + const res = await fetch("/api/auth/login", { | |
| 21 | + method: "POST", | |
| 22 | + headers: { "Content-Type": "application/json" }, | |
| 23 | + body: JSON.stringify({ username, password }), | |
| 24 | + }); | |
| 25 | + const data = await res.json(); | |
| 26 | + if (!res.ok) { | |
| 27 | + setError(data.error ?? "Erreur de connexion."); | |
| 28 | + return; | |
| 29 | + } | |
| 30 | + router.push(data.mustChangePassword ? "/changer-mot-de-passe?force=1" : "/chat"); | |
| 31 | + router.refresh(); | |
| 32 | + } catch { | |
| 33 | + setError("Impossible de joindre le serveur."); | |
| 34 | + } finally { | |
| 35 | + setLoading(false); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <div className="min-h-dvh bg-app flex flex-col items-center justify-center px-4"> | |
| 41 | + <Link href="/" className="mb-8"><ImmbotLogo size={34} /></Link> | |
| 42 | + <Card className="w-full max-w-sm p-7 animate-fade-up"> | |
| 43 | + <h1 className="text-lg font-bold text-fg">Connexion</h1> | |
| 44 | + <p className="text-sm text-muted mt-1">Accédez à vos cours d'évaluation immobilière.</p> | |
| 45 | + <form onSubmit={submit} className="mt-6 space-y-4"> | |
| 46 | + <div> | |
| 47 | + <Label htmlFor="username">Identifiant</Label> | |
| 48 | + <Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required /> | |
| 49 | + </div> | |
| 50 | + <div> | |
| 51 | + <Label htmlFor="password">Mot de passe</Label> | |
| 52 | + <Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required /> | |
| 53 | + </div> | |
| 54 | + {error && <p className="text-sm text-red-600 dark:text-red-400 bg-red-500/10 rounded-lg px-3 py-2" role="alert">{error}</p>} | |
| 55 | + <Button type="submit" className="w-full justify-center" disabled={loading}> | |
| 56 | + {loading ? <Spinner /> : "Se connecter"} | |
| 57 | + </Button> | |
| 58 | + </form> | |
| 59 | + <p className="text-[13px] text-muted mt-5 text-center"> | |
| 60 | + Pas de compte ? <Link href="/inscription" className="text-brand-500 font-medium hover:underline">S'inscrire</Link> | |
| 61 | + </p> | |
| 62 | + </Card> | |
| 63 | + <div className="mt-8"><UqoLogo height={28} className="opacity-80 dark:invert" /></div> | |
| 64 | + </div> | |
| 65 | + ); | |
| 66 | +} | |
added
app/(public)/inscription/page.tsx
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +"use client"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { useRouter } from "next/navigation"; | |
| 4 | +import { useState } from "react"; | |
| 5 | +import { ImmbotLogo, UqoLogo } from "@/components/logo"; | |
| 6 | +import { Button, Card, Input, Label, Spinner, cn } from "@/components/ui"; | |
| 7 | + | |
| 8 | +const COURSES = [ | |
| 9 | + { code: "IMM1003" as const, title: "Éléments d'évaluation immobilière", session: "Automne 2026" }, | |
| 10 | + { code: "IMM1033" as const, title: "Méthodes du coût", session: "Automne 2026" }, | |
| 11 | +]; | |
| 12 | + | |
| 13 | +export default function RegisterPage() { | |
| 14 | + const router = useRouter(); | |
| 15 | + const [form, setForm] = useState({ username: "", displayName: "", email: "", password: "", accessCode: "" }); | |
| 16 | + const [courses, setCourses] = useState<string[]>(["IMM1003"]); | |
| 17 | + const [error, setError] = useState<string | null>(null); | |
| 18 | + const [loading, setLoading] = useState(false); | |
| 19 | + | |
| 20 | + const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => | |
| 21 | + setForm((f) => ({ ...f, [k]: e.target.value })); | |
| 22 | + | |
| 23 | + async function submit(e: React.FormEvent) { | |
| 24 | + e.preventDefault(); | |
| 25 | + setError(null); | |
| 26 | + if (courses.length === 0) return setError("Choisissez au moins un cours."); | |
| 27 | + setLoading(true); | |
| 28 | + try { | |
| 29 | + const res = await fetch("/api/auth/register", { | |
| 30 | + method: "POST", | |
| 31 | + headers: { "Content-Type": "application/json" }, | |
| 32 | + body: JSON.stringify({ ...form, courses }), | |
| 33 | + }); | |
| 34 | + const data = await res.json(); | |
| 35 | + if (!res.ok) return setError(data.error ?? "Erreur d'inscription."); | |
| 36 | + router.push("/chat"); | |
| 37 | + router.refresh(); | |
| 38 | + } catch { | |
| 39 | + setError("Impossible de joindre le serveur."); | |
| 40 | + } finally { | |
| 41 | + setLoading(false); | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <div className="min-h-dvh bg-app flex flex-col items-center justify-center px-4 py-10"> | |
| 47 | + <Link href="/" className="mb-8"><ImmbotLogo size={34} /></Link> | |
| 48 | + <Card className="w-full max-w-md p-7 animate-fade-up"> | |
| 49 | + <h1 className="text-lg font-bold text-fg">Créer un compte étudiant</h1> | |
| 50 | + <p className="text-sm text-muted mt-1">Choisissez vos cours et commencez à étudier.</p> | |
| 51 | + <form onSubmit={submit} className="mt-6 space-y-4"> | |
| 52 | + <div className="grid grid-cols-2 gap-3"> | |
| 53 | + <div> | |
| 54 | + <Label htmlFor="username">Identifiant</Label> | |
| 55 | + <Input id="username" value={form.username} onChange={set("username")} autoComplete="username" required minLength={3} /> | |
| 56 | + </div> | |
| 57 | + <div> | |
| 58 | + <Label htmlFor="displayName">Nom affiché</Label> | |
| 59 | + <Input id="displayName" value={form.displayName} onChange={set("displayName")} autoComplete="name" required /> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + <div> | |
| 63 | + <Label htmlFor="email">Courriel (optionnel)</Label> | |
| 64 | + <Input id="email" type="email" value={form.email} onChange={set("email")} autoComplete="email" placeholder="prenom.nom@uqo.ca" /> | |
| 65 | + </div> | |
| 66 | + <div> | |
| 67 | + <Label htmlFor="password">Mot de passe (10 caractères minimum)</Label> | |
| 68 | + <Input id="password" type="password" value={form.password} onChange={set("password")} autoComplete="new-password" required minLength={10} /> | |
| 69 | + </div> | |
| 70 | + <div> | |
| 71 | + <Label>Vos cours</Label> | |
| 72 | + <div className="grid gap-2.5"> | |
| 73 | + {COURSES.map((c) => { | |
| 74 | + const on = courses.includes(c.code); | |
| 75 | + return ( | |
| 76 | + <button | |
| 77 | + key={c.code} | |
| 78 | + type="button" | |
| 79 | + aria-pressed={on} | |
| 80 | + onClick={() => setCourses((cs) => (on ? cs.filter((x) => x !== c.code) : [...cs, c.code]))} | |
| 81 | + className={cn( | |
| 82 | + "flex items-center justify-between px-4 py-3 rounded-xl border text-left transition-colors", | |
| 83 | + on ? "border-brand-500 bg-brand-100/60 dark:bg-brand-900/50" : "border-app bg-card hover:bg-surface-2 dark:hover:bg-brand-900/30" | |
| 84 | + )} | |
| 85 | + > | |
| 86 | + <span> | |
| 87 | + <span className="block text-sm font-semibold text-fg">{c.code} — {c.title}</span> | |
| 88 | + <span className="block text-[12px] text-muted mt-0.5">{c.session}</span> | |
| 89 | + </span> | |
| 90 | + <span className={cn("w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0", on ? "border-brand-500 bg-brand-500" : "border-app")}> | |
| 91 | + {on && <span className="text-white text-[11px] leading-none">✓</span>} | |
| 92 | + </span> | |
| 93 | + </button> | |
| 94 | + ); | |
| 95 | + })} | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | + <div> | |
| 99 | + <Label htmlFor="accessCode">Code d'accès (si fourni par le professeur)</Label> | |
| 100 | + <Input id="accessCode" value={form.accessCode} onChange={set("accessCode")} placeholder="Optionnel" /> | |
| 101 | + </div> | |
| 102 | + {error && <p className="text-sm text-red-600 dark:text-red-400 bg-red-500/10 rounded-lg px-3 py-2" role="alert">{error}</p>} | |
| 103 | + <Button type="submit" className="w-full justify-center" disabled={loading}> | |
| 104 | + {loading ? <Spinner /> : "Créer mon compte"} | |
| 105 | + </Button> | |
| 106 | + <p className="text-[11.5px] text-muted text-center leading-relaxed"> | |
| 107 | + En créant un compte, vous acceptez les{" "} | |
| 108 | + <Link href="/conditions" className="text-brand-500 hover:underline">conditions d'utilisation</Link> et la{" "} | |
| 109 | + <Link href="/confidentialite" className="text-brand-500 hover:underline">politique de confidentialité</Link>{" "} | |
| 110 | + (plateforme pédagogique réservée aux cours IMM1003 et IMM1033). | |
| 111 | + </p> | |
| 112 | + </form> | |
| 113 | + <p className="text-[13px] text-muted mt-5 text-center"> | |
| 114 | + Déjà inscrit ? <Link href="/connexion" className="text-brand-500 font-medium hover:underline">Connexion</Link> | |
| 115 | + </p> | |
| 116 | + </Card> | |
| 117 | + <div className="mt-8"><UqoLogo height={28} className="opacity-80 dark:invert" /></div> | |
| 118 | + </div> | |
| 119 | + ); | |
| 120 | +} | |
added
app/(public)/page.tsx
+421 −0
@@ -0,0 +1,421 @@ | ||
| 1 | +// Page d'accueil — plateforme pédagogique de cours (IMM1003 · IMM1033, UQO). | |
| 2 | +// Ton institutionnel et transparent : ce n'est pas un produit commercial. | |
| 3 | +import Image from "next/image"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { redirect } from "next/navigation"; | |
| 6 | +import { DemoButton } from "@/components/demo-button"; | |
| 7 | +import { | |
| 8 | + ArrowRight, BadgeCheck, BookOpen, BookOpenCheck, BrainCircuit, FileSearch, | |
| 9 | + GraduationCap, Layers, LineChart, Lock, MessageSquareText, Network, | |
| 10 | + Presentation, ShieldCheck, Timer, | |
| 11 | +} from "lucide-react"; | |
| 12 | +import { ImmbotLogo, ImmbotMark, UqoLogo } from "@/components/logo"; | |
| 13 | +import { ThemeToggle } from "@/components/theme-toggle"; | |
| 14 | +import { currentUser } from "@/lib/auth/session.ts"; | |
| 15 | +import { get } from "@/lib/db/index.ts"; | |
| 16 | + | |
| 17 | +export default async function HomePage() { | |
| 18 | + const user = await currentUser(); | |
| 19 | + if (user) redirect("/chat"); | |
| 20 | + | |
| 21 | + const chunks = get<{ n: number }>("SELECT COUNT(*) as n FROM chunks WHERE space LIKE 'official-%'")?.n ?? 2000; | |
| 22 | + const questions = get<{ n: number }>("SELECT COUNT(*) as n FROM quiz_questions")?.n ?? 129; | |
| 23 | + const cards = get<{ n: number }>("SELECT COUNT(*) as n FROM flashcards")?.n ?? 140; | |
| 24 | + const exams = get<{ n: number }>("SELECT COUNT(*) as n FROM mock_exams")?.n ?? 6; | |
| 25 | + | |
| 26 | + return ( | |
| 27 | + <div className="min-h-dvh bg-app"> | |
| 28 | + {/* Filet institutionnel */} | |
| 29 | + <div className="h-1 bg-brand-700" aria-hidden> | |
| 30 | + <div className="h-full w-24 bg-gold-500 ml-4 sm:ml-8" /> | |
| 31 | + </div> | |
| 32 | + | |
| 33 | + <header className="sticky top-0 z-40 bg-card/95 backdrop-blur border-b border-app"> | |
| 34 | + <div className="max-w-6xl mx-auto px-4 sm:px-6 h-15 sm:h-16 flex items-center justify-between gap-3"> | |
| 35 | + <div className="flex items-center gap-3 sm:gap-4 min-w-0"> | |
| 36 | + <ImmbotLogo size={28} /> | |
| 37 | + <span className="hidden sm:block w-px h-7" style={{ background: "var(--border)" }} aria-hidden /> | |
| 38 | + <UqoLogo height={30} className="hidden sm:block dark:invert" /> | |
| 39 | + </div> | |
| 40 | + <nav className="flex items-center gap-1.5 sm:gap-2 shrink-0"> | |
| 41 | + <ThemeToggle className="hidden sm:block" /> | |
| 42 | + <Link href="/connexion" className="px-3 sm:px-4 py-2 text-[13.5px] sm:text-sm font-semibold text-fg hover:bg-surface-2 dark:hover:bg-brand-900/40 rounded-lg transition-colors"> | |
| 43 | + Connexion | |
| 44 | + </Link> | |
| 45 | + <Link href="/inscription" className="px-3 sm:px-4 py-2 text-[13.5px] sm:text-sm font-semibold bg-brand-700 hover:bg-brand-800 text-white rounded-lg transition-colors"> | |
| 46 | + Créer un compte | |
| 47 | + </Link> | |
| 48 | + </nav> | |
| 49 | + </div> | |
| 50 | + {/* Bandeau de divulgation */} | |
| 51 | + <div className="bg-brand-700 text-white"> | |
| 52 | + <p className="max-w-6xl mx-auto px-4 sm:px-6 py-1.5 text-[11.5px] sm:text-[12.5px] font-medium text-center sm:text-left"> | |
| 53 | + Plateforme pédagogique réservée aux personnes inscrites à <strong>IMM1003</strong> et{" "} | |
| 54 | + <strong>IMM1033</strong> — Automne 2026 · Fournie par le professeur Simon-Pierre Boucher, UQO | |
| 55 | + </p> | |
| 56 | + </div> | |
| 57 | + </header> | |
| 58 | + | |
| 59 | + <main> | |
| 60 | + {/* ================= Héro ================= */} | |
| 61 | + <section className="relative overflow-hidden"> | |
| 62 | + {/* Quadrillage discret */} | |
| 63 | + <div | |
| 64 | + aria-hidden | |
| 65 | + className="absolute inset-0 opacity-[0.5] dark:opacity-[0.12]" | |
| 66 | + style={{ | |
| 67 | + backgroundImage: | |
| 68 | + "linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)", | |
| 69 | + backgroundSize: "44px 44px", | |
| 70 | + maskImage: "radial-gradient(ellipse 90% 65% at 50% 0%, black 35%, transparent 78%)", | |
| 71 | + WebkitMaskImage: "radial-gradient(ellipse 90% 65% at 50% 0%, black 35%, transparent 78%)", | |
| 72 | + }} | |
| 73 | + /> | |
| 74 | + <div className="relative max-w-6xl mx-auto px-4 sm:px-6 pt-10 sm:pt-16 pb-12 sm:pb-16 grid lg:grid-cols-2 gap-10 lg:gap-12 items-center"> | |
| 75 | + <div> | |
| 76 | + <p className="inline-flex items-center gap-2 text-[12px] sm:text-[13px] font-semibold text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-900/50 border border-brand-200 dark:border-brand-800 px-3.5 py-1.5 rounded-full animate-fade-up"> | |
| 77 | + <GraduationCap size={14} /> L'outil d'étude officiel de vos cours | |
| 78 | + </p> | |
| 79 | + <h1 className="mt-5 text-[2.1rem] leading-[1.1] sm:text-[3.4rem] font-bold tracking-tight text-fg animate-fade-up [animation-delay:60ms] text-balance"> | |
| 80 | + Étudiez IMM1003 et IMM1033 avec un assistant qui connaît{" "} | |
| 81 | + <span className="relative whitespace-nowrap"> | |
| 82 | + <span className="text-brand-600 dark:text-brand-300">vos diapositives</span> | |
| 83 | + <svg className="absolute -bottom-1.5 left-0 w-full" height="6" viewBox="0 0 200 6" preserveAspectRatio="none" aria-hidden> | |
| 84 | + <path d="M0 4 Q 50 0.5 100 3 T 200 2.5" stroke="#C6A300" strokeWidth="2.5" fill="none" strokeLinecap="round" /> | |
| 85 | + </svg> | |
| 86 | + </span> | |
| 87 | + . | |
| 88 | + </h1> | |
| 89 | + <p className="mt-5 text-[15px] sm:text-[17px] text-muted leading-relaxed animate-fade-up [animation-delay:120ms] text-pretty max-w-xl"> | |
| 90 | + Mise en place par votre professeur, cette plateforme est construite sur le{" "} | |
| 91 | + <strong className="text-fg font-semibold">matériel officiel des deux cours</strong> — chaque | |
| 92 | + réponse cite la séance et la diapositive exactes, que vous pouvez vérifier d'un clic. | |
| 93 | + </p> | |
| 94 | + <ul className="mt-5 space-y-2 animate-fade-up [animation-delay:160ms]"> | |
| 95 | + {[ | |
| 96 | + "Fondé uniquement sur le contenu du cours (mode par défaut)", | |
| 97 | + "Vos conversations et votre progression restent privées", | |
| 98 | + "Gratuit pour les personnes inscrites — aucun objectif commercial", | |
| 99 | + ].map((t) => ( | |
| 100 | + <li key={t} className="flex items-start gap-2.5 text-[13.5px] sm:text-[14.5px] text-fg"> | |
| 101 | + <BadgeCheck size={17} className="text-brand-600 dark:text-brand-300 shrink-0 mt-0.5" /> | |
| 102 | + {t} | |
| 103 | + </li> | |
| 104 | + ))} | |
| 105 | + </ul> | |
| 106 | + <div className="mt-7 flex flex-col sm:flex-row sm:items-center gap-2.5 animate-fade-up [animation-delay:200ms]"> | |
| 107 | + <Link href="/inscription" className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-brand-700 hover:bg-brand-800 text-white font-semibold rounded-xl shadow-md shadow-brand-700/20 transition-colors"> | |
| 108 | + Créer mon compte étudiant <ArrowRight size={16} /> | |
| 109 | + </Link> | |
| 110 | + <Link href="/connexion" className="inline-flex items-center justify-center px-6 py-3 bg-card border border-app hover:border-brand-400 text-fg font-semibold rounded-xl transition-colors"> | |
| 111 | + J'ai déjà un compte | |
| 112 | + </Link> | |
| 113 | + </div> | |
| 114 | + <div className="mt-3 animate-fade-up [animation-delay:240ms]"> | |
| 115 | + <DemoButton /> | |
| 116 | + <p className="text-[11.5px] text-muted mt-1.5"> | |
| 117 | + Compte d'essai partagé avec accès aux deux cours — vos vraies données commencent avec votre compte personnel. | |
| 118 | + </p> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + | |
| 122 | + {/* Aperçu du chat */} | |
| 123 | + <div className="animate-fade-up [animation-delay:240ms]"> | |
| 124 | + <div className="bg-card border border-app rounded-xl shadow-xl overflow-hidden"> | |
| 125 | + <div className="px-4 py-2.5 border-b border-app flex items-center gap-2.5 bg-surface-1 dark:bg-brand-950/60"> | |
| 126 | + <ImmbotMark size={20} /> | |
| 127 | + <span className="text-[12.5px] font-semibold text-fg">IMM1033 · Cours interactif</span> | |
| 128 | + <span className="ml-auto text-[11px] text-muted font-medium">Mode par défaut : matériel officiel</span> | |
| 129 | + </div> | |
| 130 | + <div className="p-4 sm:p-5 space-y-3.5 text-left"> | |
| 131 | + <div className="flex justify-end"> | |
| 132 | + <div className="bg-brand-700 text-white rounded-2xl rounded-br-md px-4 py-2.5 max-w-[88%] text-[13px] sm:text-[13.5px]"> | |
| 133 | + Que dit la séance 10 sur la dépréciation incurable de longue durée ? | |
| 134 | + </div> | |
| 135 | + </div> | |
| 136 | + <div className="flex flex-wrap gap-1.5"> | |
| 137 | + <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-app bg-surface-1 dark:bg-brand-950/40 text-[11.5px] font-medium text-muted"> | |
| 138 | + <BookOpen size={11} /> Parcourt le plan de la séance 10 | |
| 139 | + </span> | |
| 140 | + <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-app bg-surface-1 dark:bg-brand-950/40 text-[11.5px] font-medium text-muted"> | |
| 141 | + <Presentation size={11} /> Lit les diapositives 23–27 | |
| 142 | + </span> | |
| 143 | + </div> | |
| 144 | + <div className="flex gap-2.5"> | |
| 145 | + <ImmbotMark size={22} className="shrink-0 mt-1" /> | |
| 146 | + <div className="bg-surface-1 dark:bg-brand-900/40 border border-app rounded-2xl rounded-bl-md px-4 py-3 text-[13px] sm:text-[13.5px] text-fg leading-relaxed"> | |
| 147 | + La dépréciation physique incurable de longue durée touche les composantes qui durent | |
| 148 | + aussi longtemps que le bâtiment <span className="citation-chip">S1</span>. On l'estime | |
| 149 | + par la méthode <strong>âge-vie</strong> appliquée à la base résiduelle — après avoir | |
| 150 | + retiré le curable et le court terme pour éviter le{" "} | |
| 151 | + <strong>double comptage</strong> <span className="citation-chip">S3</span>. | |
| 152 | + <span className="block mt-2.5 pt-2 border-t border-app text-[11px] text-muted"> | |
| 153 | + Sources : Séance 10 — Diapositives 23 et 25 · vérifiables d'un clic | |
| 154 | + </span> | |
| 155 | + </div> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + </div> | |
| 159 | + <p className="text-center text-[11.5px] text-muted mt-2"> | |
| 160 | + Réponse réelle du mode « Cours interactif » : l'assistant consulte les diapositives sous vos yeux. | |
| 161 | + </p> | |
| 162 | + </div> | |
| 163 | + </div> | |
| 164 | + </section> | |
| 165 | + | |
| 166 | + {/* ================= Matériel indexé ================= */} | |
| 167 | + <section className="border-y border-app bg-card"> | |
| 168 | + <div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10"> | |
| 169 | + <p className="text-center text-[12px] font-bold uppercase tracking-widest text-muted"> | |
| 170 | + Le matériel de vos deux cours, indexé à la diapositive près | |
| 171 | + </p> | |
| 172 | + <div className="mt-5 grid grid-cols-2 sm:grid-cols-4 gap-px rounded-xl overflow-hidden border border-app" style={{ background: "var(--border)" }}> | |
| 173 | + {[ | |
| 174 | + [chunks.toLocaleString("fr-CA"), "extraits du matériel officiel"], | |
| 175 | + ["28", "séances consultables en ligne"], | |
| 176 | + [`${questions} + ${cards}`, "questions et cartes mémoire"], | |
| 177 | + [String(exams), "examens blancs corrigés"], | |
| 178 | + ].map(([n, label]) => ( | |
| 179 | + <div key={label} className="bg-card px-4 py-4 sm:py-5 text-center"> | |
| 180 | + <p className="text-xl sm:text-2xl font-bold text-brand-700 dark:text-brand-300 tracking-tight">{n}</p> | |
| 181 | + <p className="text-[11.5px] sm:text-[12.5px] text-muted mt-0.5">{label}</p> | |
| 182 | + </div> | |
| 183 | + ))} | |
| 184 | + </div> | |
| 185 | + </div> | |
| 186 | + </section> | |
| 187 | + | |
| 188 | + {/* ================= Trois façons d'étudier ================= */} | |
| 189 | + <section className="max-w-6xl mx-auto px-4 sm:px-6 py-14 sm:py-20"> | |
| 190 | + <h2 className="text-xl sm:text-3xl font-bold tracking-tight text-center text-fg">Trois façons d'étudier</h2> | |
| 191 | + <p className="text-center text-[13.5px] sm:text-base text-muted mt-2.5 max-w-xl mx-auto"> | |
| 192 | + Chaque module applique un principe validé par la science de l'apprentissage — pas de gadgets. | |
| 193 | + </p> | |
| 194 | + <div className="mt-9 sm:mt-12 grid md:grid-cols-3 gap-4 sm:gap-5"> | |
| 195 | + {PILLARS.map((p, i) => ( | |
| 196 | + <div key={p.title} className="relative bg-card border border-app rounded-xl p-5 sm:p-6 overflow-hidden"> | |
| 197 | + <div className="absolute inset-x-0 top-0 h-1" style={{ background: i === 1 ? "#C6A300" : "var(--color-brand-700, #003E7E)" }} /> | |
| 198 | + <div className="flex items-center gap-3"> | |
| 199 | + <div className="w-10 h-10 rounded-lg bg-brand-700 text-white flex items-center justify-center shrink-0"> | |
| 200 | + <p.icon size={19} /> | |
| 201 | + </div> | |
| 202 | + <div> | |
| 203 | + <p className="text-[11px] font-bold uppercase tracking-widest text-muted">{p.eyebrow}</p> | |
| 204 | + <h3 className="font-bold text-[16px] text-fg tracking-tight">{p.title}</h3> | |
| 205 | + </div> | |
| 206 | + </div> | |
| 207 | + <ul className="mt-4 space-y-2.5"> | |
| 208 | + {p.items.map((it) => ( | |
| 209 | + <li key={it.label} className="flex items-start gap-2.5"> | |
| 210 | + <it.icon size={15} className="text-brand-600 dark:text-brand-300 shrink-0 mt-0.5" /> | |
| 211 | + <span className="text-[13px] text-fg leading-snug"> | |
| 212 | + <strong className="font-semibold">{it.label}</strong> | |
| 213 | + <span className="text-muted"> — {it.text}</span> | |
| 214 | + </span> | |
| 215 | + </li> | |
| 216 | + ))} | |
| 217 | + </ul> | |
| 218 | + </div> | |
| 219 | + ))} | |
| 220 | + </div> | |
| 221 | + </section> | |
| 222 | + | |
| 223 | + {/* ================= La plateforme en images ================= */} | |
| 224 | + <section className="border-t border-app bg-card"> | |
| 225 | + <div className="max-w-6xl mx-auto px-4 sm:px-6 py-14 sm:py-20"> | |
| 226 | + <h2 className="text-xl sm:text-3xl font-bold tracking-tight text-center text-fg">La plateforme en images</h2> | |
| 227 | + <p className="text-center text-[13.5px] sm:text-base text-muted mt-2.5 max-w-xl mx-auto"> | |
| 228 | + Captures réelles de l'application — exactement ce que vous verrez une fois connecté·e. | |
| 229 | + </p> | |
| 230 | + <div className="mt-9 grid md:grid-cols-2 gap-5 sm:gap-7"> | |
| 231 | + {SCREENS.map((s) => ( | |
| 232 | + <figure key={s.src} className="group"> | |
| 233 | + <div className="rounded-xl border border-app bg-app shadow-lg overflow-hidden transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:shadow-xl"> | |
| 234 | + <div className="flex items-center gap-1.5 px-3.5 py-2 bg-surface-2 dark:bg-brand-950/60 border-b border-app"> | |
| 235 | + <span className="w-2.5 h-2.5 rounded-full bg-red-400/80" aria-hidden /> | |
| 236 | + <span className="w-2.5 h-2.5 rounded-full bg-amber-400/80" aria-hidden /> | |
| 237 | + <span className="w-2.5 h-2.5 rounded-full bg-emerald-400/80" aria-hidden /> | |
| 238 | + <span className="ml-2 text-[11px] font-medium text-muted truncate">{s.url}</span> | |
| 239 | + </div> | |
| 240 | + <Image | |
| 241 | + src={s.src} | |
| 242 | + alt={s.alt} | |
| 243 | + width={1280} | |
| 244 | + height={800} | |
| 245 | + className="w-full h-auto" | |
| 246 | + sizes="(max-width: 768px) 100vw, 560px" | |
| 247 | + /> | |
| 248 | + </div> | |
| 249 | + <figcaption className="mt-2.5 px-1"> | |
| 250 | + <p className="text-[13.5px] font-semibold text-fg">{s.title}</p> | |
| 251 | + <p className="text-[12.5px] text-muted mt-0.5">{s.caption}</p> | |
| 252 | + </figcaption> | |
| 253 | + </figure> | |
| 254 | + ))} | |
| 255 | + </div> | |
| 256 | + </div> | |
| 257 | + </section> | |
| 258 | + | |
| 259 | + {/* ================= Les deux cours ================= */} | |
| 260 | + <section className="border-t border-app bg-card"> | |
| 261 | + <div className="max-w-6xl mx-auto px-4 sm:px-6 py-14 sm:py-18"> | |
| 262 | + <div className="grid md:grid-cols-2 gap-4"> | |
| 263 | + <div className="relative border border-app rounded-xl p-5 sm:p-7 bg-app overflow-hidden"> | |
| 264 | + <div className="absolute inset-x-0 top-0 h-1 bg-brand-700" /> | |
| 265 | + <p className="text-[12px] font-bold text-brand-700 dark:text-brand-300 tracking-widest">IMM1003 · AUTOMNE 2026</p> | |
| 266 | + <h3 className="text-lg sm:text-xl font-bold tracking-tight text-fg mt-1.5">Éléments d'évaluation immobilière</h3> | |
| 267 | + <p className="text-[13px] sm:text-sm text-muted mt-2.5 leading-relaxed"> | |
| 268 | + Cadre professionnel OEAQ, principes économiques, types de valeur, marché québécois et les | |
| 269 | + trois méthodes reconnues — jusqu'au rapport d'évaluation conforme. | |
| 270 | + </p> | |
| 271 | + </div> | |
| 272 | + <div className="relative border border-app rounded-xl p-5 sm:p-7 bg-app overflow-hidden"> | |
| 273 | + <div className="absolute inset-x-0 top-0 h-1 bg-gold-500" /> | |
| 274 | + <p className="text-[12px] font-bold text-gold-600 dark:text-gold-400 tracking-widest">IMM1033 · AUTOMNE 2026</p> | |
| 275 | + <h3 className="text-lg sm:text-xl font-bold tracking-tight text-fg mt-1.5">Méthodes du coût en évaluation immobilière</h3> | |
| 276 | + <p className="text-[13px] sm:text-sm text-muted mt-2.5 leading-relaxed"> | |
| 277 | + Évaluation du terrain, coûts directs et indirects, profit de l'entrepreneur et la mesure | |
| 278 | + rigoureuse des trois formes de dépréciation — jusqu'au cas intégrateur complet. | |
| 279 | + </p> | |
| 280 | + </div> | |
| 281 | + </div> | |
| 282 | + <p className="text-center text-[12.5px] text-muted mt-5"> | |
| 283 | + Professeur : <strong className="text-fg font-semibold">Simon-Pierre Boucher</strong> — Département | |
| 284 | + des sciences administratives, Université du Québec en Outaouais | |
| 285 | + </p> | |
| 286 | + </div> | |
| 287 | + </section> | |
| 288 | + | |
| 289 | + {/* ================= Un outil pédagogique, pas un raccourci ================= */} | |
| 290 | + <section className="max-w-4xl mx-auto px-4 sm:px-6 py-14 sm:py-20"> | |
| 291 | + <h2 className="text-xl sm:text-3xl font-bold tracking-tight text-center text-fg"> | |
| 292 | + Un outil pédagogique — pas un raccourci | |
| 293 | + </h2> | |
| 294 | + <div className="mt-8 grid sm:grid-cols-3 gap-4"> | |
| 295 | + {[ | |
| 296 | + { | |
| 297 | + icon: ShieldCheck, | |
| 298 | + title: "Transparent", | |
| 299 | + text: "L'origine de chaque information est identifiée : matériel officiel (cité par diapositive), vos documents, Web ou connaissances du modèle. Quand le cours ne couvre pas une question, l'assistant le dit.", | |
| 300 | + }, | |
| 301 | + { | |
| 302 | + icon: BookOpenCheck, | |
| 303 | + title: "Honnête avec vos travaux", | |
| 304 | + text: "Pour un atelier noté, l'assistant vous demande d'abord votre tentative et guide par indices — il est là pour vous préparer à l'examen, pas pour faire le travail à votre place.", | |
| 305 | + }, | |
| 306 | + { | |
| 307 | + icon: Lock, | |
| 308 | + title: "Privé", | |
| 309 | + text: "Vos conversations et votre progression individuelle ne sont jamais montrées au professeur — il ne voit que des statistiques agrégées et anonymisées. Aucune donnée n'est vendue ni utilisée à d'autres fins.", | |
| 310 | + }, | |
| 311 | + ].map((c) => ( | |
| 312 | + <div key={c.title} className="text-center px-2"> | |
| 313 | + <c.icon className="mx-auto text-brand-600 dark:text-brand-300" size={26} /> | |
| 314 | + <h3 className="font-bold text-[15px] text-fg mt-3 tracking-tight">{c.title}</h3> | |
| 315 | + <p className="text-[12.5px] text-muted mt-2 leading-relaxed">{c.text}</p> | |
| 316 | + </div> | |
| 317 | + ))} | |
| 318 | + </div> | |
| 319 | + <div className="mt-10 text-center"> | |
| 320 | + <Link href="/inscription" className="inline-flex items-center gap-2 px-6 py-3 bg-brand-700 hover:bg-brand-800 text-white font-semibold rounded-xl transition-colors"> | |
| 321 | + Accéder à la plateforme <ArrowRight size={16} /> | |
| 322 | + </Link> | |
| 323 | + <p className="text-[12px] text-muted mt-3"> | |
| 324 | + Réservé aux personnes inscrites aux cours. L'assistant peut se tromper : vérifiez les sources citées. | |
| 325 | + </p> | |
| 326 | + </div> | |
| 327 | + </section> | |
| 328 | + </main> | |
| 329 | + | |
| 330 | + <footer className="border-t border-app bg-card"> | |
| 331 | + <div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center sm:items-start justify-between gap-5"> | |
| 332 | + <div className="flex flex-col items-center sm:items-start gap-3 max-w-md text-center sm:text-left"> | |
| 333 | + <div className="flex items-center gap-4"> | |
| 334 | + <ImmbotLogo size={24} /> | |
| 335 | + <span className="w-px h-6" style={{ background: "var(--border)" }} aria-hidden /> | |
| 336 | + <UqoLogo height={26} className="dark:invert" /> | |
| 337 | + </div> | |
| 338 | + <p className="text-[11.5px] text-muted leading-relaxed"> | |
| 339 | + Immbot AI est un outil pédagogique fourni gratuitement aux étudiantes et étudiants | |
| 340 | + d'IMM1003 et IMM1033 par le professeur Simon-Pierre Boucher (UQO). Il ne remplace ni les | |
| 341 | + séances, ni le matériel officiel, ni le plan de cours. | |
| 342 | + </p> | |
| 343 | + </div> | |
| 344 | + <div className="text-[11.5px] text-muted text-center sm:text-right space-y-1 shrink-0"> | |
| 345 | + <p>IMM1003 · IMM1033 — Automne 2026</p> | |
| 346 | + <p>Université du Québec en Outaouais</p> | |
| 347 | + <p>simon-pierre.boucher@uqo.ca</p> | |
| 348 | + <p className="pt-1.5 space-x-3"> | |
| 349 | + <Link href="/conditions" className="font-semibold text-fg hover:underline">Conditions d'utilisation</Link> | |
| 350 | + <Link href="/confidentialite" className="font-semibold text-fg hover:underline">Confidentialité</Link> | |
| 351 | + </p> | |
| 352 | + </div> | |
| 353 | + </div> | |
| 354 | + </footer> | |
| 355 | + </div> | |
| 356 | + ); | |
| 357 | +} | |
| 358 | + | |
| 359 | +const SCREENS = [ | |
| 360 | + { | |
| 361 | + src: "/screens/chat.png", | |
| 362 | + url: "immbot.ai/chat", | |
| 363 | + alt: "Le chat d'Immbot AI répond avec des citations vérifiables et montre ses consultations du cours", | |
| 364 | + title: "Le chat qui montre ses sources", | |
| 365 | + caption: "L'assistant consulte le plan et lit les diapositives sous vos yeux, puis cite chaque affirmation — cliquez S1 pour voir l'extrait original.", | |
| 366 | + }, | |
| 367 | + { | |
| 368 | + src: "/screens/diapositives.png", | |
| 369 | + url: "immbot.ai/apprendre/imm1033/diapositives", | |
| 370 | + alt: "La visionneuse de diapositives avec sommaire et encadrés colorés", | |
| 371 | + title: "Les 28 séances consultables en ligne", | |
| 372 | + caption: "Navigation au clavier, recherche dans la séance, définitions et formules en encadrés, PDF original téléchargeable.", | |
| 373 | + }, | |
| 374 | + { | |
| 375 | + src: "/screens/progression.png", | |
| 376 | + url: "immbot.ai/apprendre/imm1003", | |
| 377 | + alt: "Le tableau de progression avec maîtrise estimée, série de jours et statistiques", | |
| 378 | + title: "Votre progression, honnêtement", | |
| 379 | + caption: "Maîtrise estimée par notion, série de jours d'étude, cartes dues et recommandations justifiées — visible par vous seul·e.", | |
| 380 | + }, | |
| 381 | + { | |
| 382 | + src: "/screens/concepts.png", | |
| 383 | + url: "immbot.ai/apprendre/imm1003/concepts", | |
| 384 | + alt: "La carte interactive des concepts du cours, colorée selon la maîtrise", | |
| 385 | + title: "La carte des concepts du cours", | |
| 386 | + caption: "Les notions semaine par semaine avec leurs préalables, colorées selon votre maîtrise — un clic pour réviser, quizzer ou en discuter.", | |
| 387 | + }, | |
| 388 | +] as const; | |
| 389 | + | |
| 390 | +const PILLARS = [ | |
| 391 | + { | |
| 392 | + eyebrow: "Comprendre", | |
| 393 | + title: "Poser des questions au cours", | |
| 394 | + icon: MessageSquareText, | |
| 395 | + items: [ | |
| 396 | + { icon: FileSearch, label: "Citations exactes", text: "chaque réponse renvoie à la séance et à la diapositive, vérifiables d'un clic" }, | |
| 397 | + { icon: BrainCircuit, label: "Cours interactif", text: "l'assistant consulte le plan et lit les diapositives sous vos yeux" }, | |
| 398 | + { icon: Presentation, label: "Diapositives en ligne", text: "les 28 séances consultables avec recherche, sommaire et PDF original" }, | |
| 399 | + ], | |
| 400 | + }, | |
| 401 | + { | |
| 402 | + eyebrow: "Pratiquer", | |
| 403 | + title: "Se tester régulièrement", | |
| 404 | + icon: BookOpenCheck, | |
| 405 | + items: [ | |
| 406 | + { icon: Layers, label: "Cartes mémoire", text: "répétition espacée pour retenir formules et taxonomies durablement" }, | |
| 407 | + { icon: BookOpenCheck, label: "Quiz adaptatifs", text: "la difficulté suit vos réponses et cible vos faiblesses" }, | |
| 408 | + { icon: Timer, label: "Examens blancs", text: "intra et final simulés, chronométrés, corrigés par compétence" }, | |
| 409 | + ], | |
| 410 | + }, | |
| 411 | + { | |
| 412 | + eyebrow: "Progresser", | |
| 413 | + title: "Savoir où vous en êtes", | |
| 414 | + icon: LineChart, | |
| 415 | + items: [ | |
| 416 | + { icon: Network, label: "Carte des concepts", text: "les notions du cours et leurs préalables, colorées selon votre maîtrise" }, | |
| 417 | + { icon: LineChart, label: "Progression honnête", text: "une estimation par notion, fondée sur la difficulté et la récence" }, | |
| 418 | + { icon: GraduationCap, label: "Plan d'étude", text: "généré depuis votre date d'examen et vos disponibilités, il s'adapte" }, | |
| 419 | + ], | |
| 420 | + }, | |
| 421 | +] as const; | |
added
app/api/admin/announcements/route.ts
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +import { NextResponse } from "next/server"; | |
| 2 | +import { z } from "zod"; | |
| 3 | +import { apiError, parseBody } from "@/lib/api.ts"; | |
| 4 | +import { assertSameOrigin, requireRole } from "@/lib/auth/session.ts"; | |
| 5 | +import { all, run } from "@/lib/db/index.ts"; | |
| 6 | + | |
| 7 | +export async function GET() { | |
| 8 | + try { | |
| 9 | + await requireRole("instructor"); | |
| 10 | + return NextResponse.json({ announcements: all("SELECT * FROM announcements ORDER BY id DESC LIMIT 100") }); | |
| 11 | + } catch (e) { | |
| 12 | + return apiError(e); | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +const createSchema = z.object({ | |
| 17 | + action: z.literal("create"), | |
| 18 | + title: z.string().min(1).max(200), | |
| 19 | + body: z.string().min(1).max(10_000), | |
| 20 | + courseCode: z.enum(["IMM1003", "IMM1033"]).nullable(), | |
| 21 | + pinned: z.boolean().default(false), | |
| 22 | +}); | |
| 23 | +const updateSchema = z.object({ | |
| 24 | + action: z.literal("update"), | |
| 25 | + id: z.number().int().positive(), | |
| 26 | + active: z.boolean().optional(), | |
| 27 | + pinned: z.boolean().optional(), | |
| 28 | +}); | |
| 29 | +const deleteSchema = z.object({ action: z.literal("delete"), id: z.number().int().positive() }); | |
| 30 | + | |
| 31 | +export async function POST(req: Request) { | |
| 32 | + try { | |
| 33 | + await assertSameOrigin(); | |
| 34 | + const user = await requireRole("instructor"); | |
| 35 | + const body = await parseBody(req, z.discriminatedUnion("action", [createSchema, updateSchema, deleteSchema])); | |
| 36 | + if (body.action === "create") { | |
| 37 | + run( | |
| 38 | + "INSERT INTO announcements (title, body, course_code, pinned, created_by) VALUES (?, ?, ?, ?, ?)", | |
| 39 | + body.title, body.body, body.courseCode, body.pinned ? 1 : 0, user.id | |
| 40 | + ); | |
| 41 | + } else if (body.action === "update") { | |
| 42 | + if (body.active !== undefined) run("UPDATE announcements SET active = ? WHERE id = ?", body.active ? 1 : 0, body.id); | |
| 43 | + if (body.pinned !== undefined) run("UPDATE announcements SET pinned = ? WHERE id = ?", body.pinned ? 1 : 0, body.id); | |
| 44 | + } else { | |
| 45 | + run("DELETE FROM announcements WHERE id = ?", body.id); | |
| 46 | + } | |
| 47 | + return NextResponse.json({ ok: true }); | |
| 48 | + } catch (e) { | |
| 49 | + return apiError(e); | |
| 50 | + } | |
| 51 | +} | |
added
app/api/admin/ingest/route.ts
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +import { NextResponse } from "next/server"; | |
| 2 | +import { apiError } from "@/lib/api.ts"; | |
| 3 | +import { assertSameOrigin, requireRole } from "@/lib/auth/session.ts"; | |
| 4 | +import { all } from "@/lib/db/index.ts"; | |
| 5 | +import { ingestCourses } from "@/lib/rag/ingest.ts"; | |
| 6 | + | |
| 7 | +export const maxDuration = 600; | |
| 8 | + | |
| 9 | +export async function GET() { | |
| 10 | + try { | |
| 11 | + await requireRole("instructor"); | |
| 12 | + const runs = all("SELECT id, started_at, finished_at, triggered_by, files_scanned, files_ingested, files_skipped, chunks_created, status, report FROM ingestion_runs ORDER BY id DESC LIMIT 10"); | |
| 13 | + const documents = all( | |
| 14 | + `SELECT id, course_code, space, filename, doc_type, title, week, status, error, visible_to_students, ingested_at, chunk_count | |
| 15 | + FROM documents ORDER BY course_code, space, week, filename` | |
| 16 | + ); | |
| 17 | + return NextResponse.json({ runs, documents }); | |
| 18 | + } catch (e) { | |
| 19 | + return apiError(e); | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +export async function POST(req: Request) { | |
| 24 | + try { | |
| 25 | + await assertSameOrigin(); | |
| 26 | + const user = await requireRole("instructor"); | |
| 27 | + const force = new URL(req.url).searchParams.get("force") === "1"; | |
| 28 | + const result = await ingestCourses({ force, triggeredBy: `admin:${user.username}` }); | |
| 29 | + return NextResponse.json({ ok: true, ...result }); | |
| 30 | + } catch (e) { | |
| 31 | + return apiError(e); | |
| 32 | + } | |
| 33 | +} | |
added
app/api/admin/models/route.ts
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/admin/overview/route.ts
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/admin/pedagogy/route.ts
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/admin/prompts/route.ts
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/admin/settings/route.ts
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/admin/users/route.ts
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/announcements/route.ts
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/auth/change-password/route.ts
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/auth/demo/route.ts
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/auth/login/route.ts
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/auth/logout/route.ts
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/auth/register/route.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/chat/route.ts
+297 −0
Large diff (297 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/citations/[chunkId]/route.ts
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/conversations/[id]/branch/route.ts
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/conversations/[id]/route.ts
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/conversations/route.ts
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/health/route.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/[course]/concepts/route.ts
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/[course]/flashcards/route.ts
+142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/[course]/overview/route.ts
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/[course]/plan/route.ts
+88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/[course]/summaries/route.ts
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/errors/route.ts
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/exams/route.ts
+149 −0
Large diff (149 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/flashcards/review/route.ts
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/learning/quiz/route.ts
+160 −0
Large diff (160 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/library/route.ts
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/messages/[id]/route.ts
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/models/route.ts
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/slides/[course]/[week]/route.ts
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/slides/[course]/route.ts
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
app/api/uploads/route.ts
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
app/globals.css
+186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added
app/layout.tsx
+47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/announcements.tsx
+269 −0
Large diff (269 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/courses.tsx
+325 −0
Large diff (325 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/models.tsx
+418 −0
Large diff (418 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/nav.tsx
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/overview.tsx
+179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/pedagogy.tsx
+291 −0
Large diff (291 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/prompts.tsx
+225 −0
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/security.tsx
+323 −0
Large diff (323 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/settings.tsx
+258 −0
Large diff (258 lines) is not loaded by default to keep the page fast.Load diff
added
components/admin/shared.tsx
+250 −0
Large diff (250 lines) is not loaded by default to keep the page fast.Load diff
added
components/app-shell.tsx
+177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added
components/chat/chat-app.tsx
+869 −0
Large diff (869 lines) is not loaded by default to keep the page fast.Load diff
added
components/chat/citation-panel.tsx
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
components/chat/markdown.tsx
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
components/demo-button.tsx
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/concept-map.tsx
+367 −0
Large diff (367 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/course-nav.tsx
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/errors-app.tsx
+191 −0
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/exams-app.tsx
+465 −0
Large diff (465 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/flashcards-app.tsx
+419 −0
Large diff (419 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/learn-hub.tsx
+183 −0
Large diff (183 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/library-app.tsx
+163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/plan-app.tsx
+292 −0
Large diff (292 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/progress-dashboard.tsx
+256 −0
Large diff (256 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/quiz-app.tsx
+333 −0
Large diff (333 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/settings-app.tsx
+197 −0
Large diff (197 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/shared.tsx
+129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/slides-viewer.tsx
+312 −0
Large diff (312 lines) is not loaded by default to keep the page fast.Load diff
added
components/learning/summaries-app.tsx
+268 −0
Large diff (268 lines) is not loaded by default to keep the page fast.Load diff
added
components/logo.tsx
+55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added
components/theme-toggle.tsx
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
components/ui/index.tsx
+225 −0
Large diff (225 lines) is not loaded by default to keep the page fast.Load diff
added
docker-compose.yml
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
docs/admin-guide.md
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
docs/api.md
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
docs/course-content-map.md
+95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
added
docs/data-model.md
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
docs/deployment.md
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
docs/ingestion-report.md
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
docs/ingestion.md
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
docs/learning-science-strategy.md
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
docs/openrouter.md
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
docs/product-requirements.md
+86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added
docs/rag-architecture.md
+83 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added
docs/repository-analysis.md
+92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
docs/security-model.md
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
docs/security.md
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
docs/setup.md
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
docs/student-guide.md
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
docs/technical-architecture.md
+82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added
docs/testing.md
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
docs/troubleshooting.md
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
docs/web-research.md
+362 −0
Large diff (362 lines) is not loaded by default to keep the page fast.Load diff
added
evaluation/imm1003-test-set.json
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
evaluation/imm1033-test-set.json
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
evaluation/rag-evaluation.md
+64 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
lib/api.ts
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
lib/auth/password.ts
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
lib/auth/rate-limit.ts
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
lib/auth/session.ts
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
lib/db/index.ts
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
lib/db/schema.ts
+451 −0
Large diff (451 lines) is not loaded by default to keep the page fast.Load diff
added
lib/learning/helpers.ts
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
lib/learning/mastery.ts
+125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added
lib/learning/plan.ts
+100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
lib/learning/quiz.ts
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
lib/learning/recommend.ts
+131 −0
Large diff (131 lines) is not loaded by default to keep the page fast.Load diff
added
lib/learning/sm2.ts
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
lib/openrouter/client.ts
+241 −0
Large diff (241 lines) is not loaded by default to keep the page fast.Load diff
added
lib/openrouter/registry.ts
+166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added
lib/prompts.ts
+149 −0
Large diff (149 lines) is not loaded by default to keep the page fast.Load diff
added
lib/rag/citations.ts
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
lib/rag/embeddings.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
lib/rag/ingest.ts
+327 −0
Large diff (327 lines) is not loaded by default to keep the page fast.Load diff
added
lib/rag/latex.ts
+346 −0
Large diff (346 lines) is not loaded by default to keep the page fast.Load diff
added
lib/rag/search.ts
+203 −0
Large diff (203 lines) is not loaded by default to keep the page fast.Load diff
added
lib/rag/tools.ts
+0 −0
Binary file not shown.
added
lib/usage.ts
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
lib/web/tools.ts
+112 −0
Large diff (112 lines) is not loaded by default to keep the page fast.Load diff
added
next.config.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
package.json
+55 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-lock.yaml
+3340 −0
Large diff (3,340 lines) is not loaded by default to keep the page fast.Load diff
Diff truncated — file too large.