diff --git a/server/src/seed.ts b/server/src/seed.ts index dcdc028..6ab5f17 100644 --- a/server/src/seed.ts +++ b/server/src/seed.ts @@ -6,7 +6,6 @@ import { existsSync, rmSync } from 'node:fs' import { resolve } from 'node:path' import { db, nowIso } from './db.js' -import { newToken } from './auth.js' import type { WeatherKind } from '@meteo-exam/shared' const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db') @@ -88,13 +87,9 @@ const seed = db.transaction(() => { const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?') .get(CLASS_CODE)!.id - // Pas de membre admin créé par le seed : la première personne qui rejoint - // la classe via l'app devient automatiquement admin (cf. joinClass). - // Quelques élèves fictifs pour donner du volume aux votes du radar plus tard. - for (const nick of ['Léa M.', 'Karim B.', 'Chloé D.', 'Inès R.', 'Lucas P.', 'Mehdi A.']) { - db.prepare('INSERT INTO members (class_id, nickname, role, is_creator, token, created_at) VALUES (?, ?, ?, ?, ?, ?)') - .run(classId, nick, 'student', 0, newToken(), nowIso()) - } + // Pas de membre fictif : la première personne qui rejoint la classe + // via l'app devient automatiquement admin (cf. joinClass). + // Les vrais membres apparaissent ici quand ils rejoignent. const insert = db.prepare( `INSERT INTO exams diff --git a/shared/src/index.ts b/shared/src/index.ts index 312d95e..f5dde43 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -199,4 +199,4 @@ export type StreamEvent = export const CLASS_CODE_LENGTH = 6 /** Version de l'application. Incrémentée à chaque release APK. */ -export const APP_VERSION = '0.4.2' +export const APP_VERSION = '0.5.0' diff --git a/web/src/views/plus.ts b/web/src/views/plus.ts index 3183f3b..fd35715 100644 --- a/web/src/views/plus.ts +++ b/web/src/views/plus.ts @@ -1,12 +1,21 @@ +import type { Exam, Member, Role, WeatherKind } from '@meteo-exam/shared' +import { WEATHER, WEATHER_KINDS, kindFromWeight } from '@meteo-exam/shared' import { h, mount } from '../dom' -import type { Member, Role } from '@meteo-exam/shared' -import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config' import { api } from '../api' +import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config' +import { dayKey, formatDuration, formatTime, longDate, shortDate } from '../dates' import { + clearDayWeather, currentFlash, currentKlass, + dayStatus, + examsByDay, getState, isAdmin, + isDayOverridden, + removeExam, + saveExam, + setDayWeather, setFlash, signOut, updateClass, @@ -19,6 +28,8 @@ const inputClass = const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5' +const btnClass = 'w-full text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform' + export function renderPlus(root: HTMLElement, rerender: () => void): void { const klass = currentKlass() const flash = currentFlash() @@ -50,76 +61,64 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { const children: (Node | null)[] = [] - // --- Panneau admin --- + // === PANNEAU ADMIN COMPLET ================================================ if (admin) { - const membersSection = h('div', { class: 'pt-3 border-t border-slate-700/60' }, - h('span', { class: labelClass }, 'Membres de la classe'), - h('div', { id: 'members-list', class: 'space-y-1.5' }, - h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Chargement…')), - ) - - children.push( - h( - 'div', - { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' }, - h('div', { class: 'flex items-center gap-2' }, - h('i', { class: 'fa-solid fa-user-shield text-sky-400' }), - h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'), - ), - - // Flash info - h('div', {}, - h('span', { class: labelClass }, 'Flash info du jour'), - flashInput, - h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' }, - 'Message visible par toute la classe sur l’écran Météo.'), - h( - 'button', - { - class: 'mt-2 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', - onclick: async () => { - try { - await setFlash(flashInput.value.trim()) - showToast('Flash info mis à jour', 'success') - rerender() - } catch (error) { - showToast(error instanceof Error ? error.message : 'Erreur', 'reject') - } - }, - }, - 'Publier le flash info', - ), - ), - - // Édition classe - h('div', { class: 'pt-3 border-t border-slate-700/60' }, - h('span', { class: labelClass }, 'Nom de la classe'), - h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput), - h( - 'button', - { - class: 'mt-2 w-full bg-indigo-500 text-white text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', - onclick: async () => { - try { - await updateClass(nameInput.value.trim(), levelInput.value.trim() || null) - showToast('Classe mise à jour', 'success') - rerender() - } catch (error) { - showToast(error instanceof Error ? error.message : 'Erreur', 'reject') - } - }, - }, - 'Enregistrer la classe', - ), - ), - - // Membres - membersSection, + const adminPanel = h( + 'div', + { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' }, + h('div', { class: 'flex items-center gap-2' }, + h('i', { class: 'fa-solid fa-user-shield text-sky-400' }), + h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'), ), - ) - // Charger les membres de manière asynchrone - void loadMembers(membersSection.querySelector('#members-list')!, selfMember, rerender) + // --- Flash info --- + h('div', {}, + h('span', { class: labelClass }, 'Flash info du jour'), + flashInput, + h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' }, + 'Message visible par toute la classe sur l’écran Météo.'), + h('button', { + class: `${btnClass} bg-sky-500 text-slate-950 mt-2`, + onclick: async () => { + try { + await setFlash(flashInput.value.trim()) + showToast('Flash info mis à jour', 'success') + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, 'Publier le flash info'), + ), + + // --- Édition classe --- + h('div', { class: 'pt-3 border-t border-slate-700/60' }, + h('span', { class: labelClass }, 'Nom de la classe'), + h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput), + h('button', { + class: `${btnClass} bg-indigo-500 text-white mt-2`, + onclick: async () => { + try { + await updateClass(nameInput.value.trim(), levelInput.value.trim() || null) + showToast('Classe mise à jour', 'success') + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, 'Enregistrer la classe'), + ), + + // --- Gestion des évaluations --- + examsAdminSection(rerender), + + // --- Météo par jour --- + weatherAdminSection(rerender), + + // --- Membres --- + membersAdminSection(selfMember, rerender), + ) + children.push(adminPanel) } // --- Serveur --- @@ -131,18 +130,14 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { serverInput, h('p', { class: 'text-[10px] text-slate-500 font-semibold mt-2' }, 'Adresse de l’API. Émulateur : 10.0.2.2:8787 · Réseau local : IP du PC · Public : URL Tailscale.'), - h( - 'button', - { - class: 'mt-3 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', - onclick: () => { - setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL) - showToast('Adresse du serveur enregistrée', 'success') - rerender() - }, + h('button', { + class: `${btnClass} bg-sky-500 text-slate-950 mt-3`, + onclick: () => { + setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL) + showToast('Adresse du serveur enregistrée', 'success') + rerender() }, - 'Enregistrer l’adresse', - ), + }, 'Enregistrer l’adresse'), ), ) @@ -152,18 +147,10 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { 'div', { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' }, h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-1' }, 'Session'), - h( - 'button', - { - class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', - onclick: () => { - signOut() - rerender() - }, - }, - h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), - 'Se déconnecter', - ), + h('button', { + class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', + onclick: () => { signOut(); rerender() }, + }, h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), 'Se déconnecter'), ), ) @@ -173,9 +160,7 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-2' }, 'Légende météorologique'), h('div', { class: 'grid grid-cols-1 gap-2.5' }, ...legendItems.map((item) => - h( - 'div', - { class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' }, + h('div', { class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' }, h('span', { class: 'text-3xl' }, item.icon), h('div', {}, h('h4', { class: `text-sm font-black ${item.accent}` }, item.title), @@ -188,17 +173,208 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { ) children.push( - h( - 'p', - { class: 'text-[10px] text-slate-600 font-semibold text-center' }, - `Météo Exam — v${currentAppVersion()} · Radar de rumeurs en phase 2`, - ), + h('p', { class: 'text-[10px] text-slate-600 font-semibold text-center' }, + `Météo Exam — v${currentAppVersion()} · Radar de rumeurs en phase 2`), ) mount(root, ...children.filter((c): c is Node => c !== null)) } -/** Charge et affiche la liste des membres avec les boutons promote/demote. */ +// === SECTION ÉVALUATIONS ===================================================== + +function examsAdminSection(rerender: () => void): HTMLElement { + const { stripDays } = getState() + const grouped = examsByDay() + + const rows = stripDays.flatMap((date) => { + const key = dayKey(date) + const exams = grouped.get(key) ?? [] + return exams.map((exam) => examRow(exam, rerender)) + }) + + return h( + 'div', + { class: 'pt-3 border-t border-slate-700/60' }, + h('div', { class: 'flex items-center justify-between mb-2' }, + h('span', { class: labelClass }, 'Évaluations (7 jours)'), + h('span', { class: 'text-[10px] text-slate-500 font-bold' }, `${rows.length} au total`), + ), + h('div', { class: 'space-y-1.5 max-h-[300px] overflow-y-auto no-scrollbar' }, + rows.length === 0 + ? h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucune évaluation sur les 7 jours.') + : null, + ...rows, + ), + h('button', { + class: `${btnClass} bg-sky-500 text-slate-950 mt-2`, + onclick: () => openExamForm(undefined, rerender), + }, h('i', { class: 'fa-solid fa-plus mr-1.5' }), 'Ajouter une évaluation'), + ) +} + +function examRow(exam: Exam, rerender: () => void): HTMLElement { + const meta = WEATHER[exam.kind] + return h( + 'div', + { class: 'flex items-center justify-between gap-2 bg-slate-900/40 border border-slate-700/40 rounded-xl px-3 py-2' }, + h('div', { class: 'flex items-center gap-2 min-w-0' }, + h('span', { class: 'text-lg' }, meta.icon), + h('div', { class: 'min-w-0' }, + h('p', { class: 'text-xs font-bold text-slate-200 truncate' }, exam.subject), + h('p', { class: 'text-[10px] text-slate-500 font-semibold truncate' }, + `${shortDate(new Date(exam.startsAt))} · ${formatTime(exam.startsAt)} · ${formatDuration(exam.durationMin)}`), + ), + ), + h('div', { class: 'flex gap-1.5 shrink-0' }, + h('button', { + class: 'px-2 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 text-[10px] font-extrabold', + onclick: () => openExamForm(exam, rerender), + }, 'Modifier'), + h('button', { + class: 'px-2 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-[10px] font-extrabold', + onclick: async () => { + try { + await removeExam(exam.id) + showToast('Évaluation supprimée', 'success') + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, 'Suppr.'), + ), + ) +} + +// === SECTION MÉTÉO PAR JOUR =================================================== + +function weatherAdminSection(rerender: () => void): HTMLElement { + const { stripDays } = getState() + + const dayButtons = stripDays.map((date) => { + const key = dayKey(date) + const status = dayStatus(key) + const overridden = isDayOverridden(key) + const meta = WEATHER[status] + + return h( + 'button', + { + class: `flex items-center gap-2 w-full px-3 py-2 rounded-xl border text-left transition-colors ${ + overridden ? 'bg-sky-500/10 border-sky-400/30' : 'bg-slate-900/40 border-slate-700/40' + }`, + onclick: () => openDayWeatherModal(key, rerender), + }, + h('span', { class: 'text-lg' }, meta.icon), + h('div', { class: 'flex-1 min-w-0' }, + h('p', { class: 'text-xs font-bold text-slate-200' }, shortDate(date)), + h('p', { class: `text-[10px] font-semibold ${meta.accent}` }, meta.title), + ), + overridden + ? h('span', { class: 'text-[9px] font-black text-sky-400 uppercase' }, 'forcé') + : h('span', { class: 'text-[9px] text-slate-500 font-semibold' }, 'auto'), + ) + }) + + return h( + 'div', + { class: 'pt-3 border-t border-slate-700/60' }, + h('span', { class: labelClass }, 'Météo par jour'), + h('p', { class: 'text-[10px] text-slate-600 font-semibold mb-2' }, + 'Force un statut météo pour un jour. Visible par toute la classe.'), + h('div', { class: 'space-y-1.5' }, ...dayButtons), + ) +} + +function openDayWeatherModal(dayKeyValue: string, rerender: () => void): void { + const overlay = h('div', { + class: 'fixed inset-0 z-[95] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center', + }) + const close = (): void => overlay.remove() + const [y, m, d] = dayKeyValue.split('-').map(Number) + const date = new Date(y!, (m ?? 1) - 1, d ?? 1) + + const options = h( + 'div', + { class: 'grid grid-cols-1 gap-2' }, + ...(['soleil', 'brouillard', 'vent', 'orage', 'tempete'] as const).map((kind) => { + const meta = WEATHER[kind] + return h( + 'button', + { + class: `flex items-center gap-3 p-3 rounded-xl border text-left transition-colors bg-slate-800/60 border-slate-700 text-slate-300`, + onclick: async () => { + try { + await setDayWeather(dayKeyValue, kind) + showToast(`Météo forcée : ${meta.label}`, 'success') + close() + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, + h('span', { class: 'text-2xl' }, meta.icon), + h('div', {}, + h('p', { class: 'text-sm font-black' }, meta.title), + h('p', { class: 'text-[11px] font-semibold opacity-70' }, meta.hint), + ), + ) + }), + isDayOverridden(dayKeyValue) + ? h('button', { + class: 'w-full mt-2 bg-slate-800 border border-slate-700 text-slate-300 text-xs font-extrabold py-2.5 rounded-xl', + onclick: async () => { + try { + await clearDayWeather(dayKeyValue) + showToast('Override retiré', 'info') + close() + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, h('i', { class: 'fa-solid fa-rotate-left mr-1.5' }), 'Retirer (météo auto)') + : null, + ) + + const panel = h( + 'div', + { class: 'w-full sm:max-w-md bg-slate-900 border border-slate-700 rounded-t-[2rem] sm:rounded-[2rem] p-5 max-h-[85vh] overflow-y-auto no-scrollbar shadow-2xl' }, + h('div', { class: 'flex items-start justify-between gap-3 mb-4' }, + h('div', {}, + h('h2', { class: 'text-lg font-black text-slate-100' }, 'Météo du jour'), + h('p', { class: 'text-xs text-slate-400 font-semibold mt-0.5' }, longDate(date)), + ), + h('button', { class: 'text-slate-400 text-lg', onclick: close }, h('i', { class: 'fa-solid fa-xmark' })), + ), + options, + ) + + overlay.append(panel) + overlay.addEventListener('click', (event) => { if (event.target === overlay) close() }) + document.body.append(overlay) +} + +// === SECTION MEMBRES ========================================================== + +function membersAdminSection( + selfMember: { id: number; role: Role } | null, + rerender: () => void, +): HTMLElement { + const container = h('div', { id: 'members-list', class: 'space-y-1.5' }, + h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Chargement…')) + + void loadMembers(container, selfMember, rerender) + + return h( + 'div', + { class: 'pt-3 border-t border-slate-700/60' }, + h('span', { class: labelClass }, 'Membres de la classe'), + container, + ) +} + async function loadMembers( container: HTMLElement, selfMember: { id: number; role: Role } | null, @@ -207,19 +383,17 @@ async function loadMembers( try { const members = await api.listMembers() container.replaceChildren() - if (members.length === 0) { container.append(h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucun membre.')) return } - for (const member of members) { - container.append(memberRow(member, selfMember, container, rerender)) + container.append(memberRow(member, selfMember, rerender)) } } catch (error) { container.replaceChildren( h('p', { class: 'text-[11px] text-rose-400 font-semibold' }, - error instanceof Error ? error.message : 'Erreur de chargement'), + error instanceof Error ? error.message : 'Erreur'), ) } } @@ -227,49 +401,37 @@ async function loadMembers( function memberRow( member: Member, selfMember: { id: number; role: Role } | null, - container: HTMLElement, rerender: () => void, ): HTMLElement { const isSelf = selfMember?.id === member.id - const isCreator = member.isCreator - const isAdmin = member.role === 'admin' + const memberIsAdmin = member.role === 'admin' - const badge = isCreator + const badge = member.isCreator ? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-amber-500/15 text-amber-400 border border-amber-400/30' }, 'Créateur') - : isAdmin + : memberIsAdmin ? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-sky-500/15 text-sky-400 border border-sky-400/30' }, 'Admin') : h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-slate-600/30 text-slate-400 border border-slate-600/50' }, 'Élève') const actions = h('div', { class: 'flex gap-1.5 shrink-0' }) - // Promouvoir un élève en admin - if (!isAdmin) { - actions.append( - h('button', { - class: 'px-2 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 text-[10px] font-extrabold transition-colors', - title: 'Promouvoir admin', - onclick: () => changeRole(member.id, 'admin', container, rerender), - }, '↑ Admin'), - ) + if (!memberIsAdmin) { + actions.append(h('button', { + class: 'px-2 py-1 rounded-lg bg-sky-500/10 text-sky-400 text-[10px] font-extrabold', + onclick: () => changeRole(member.id, 'admin', rerender), + }, '↑ Admin')) } - // Rétrograder un admin (sauf créateur et soi-même) - if (isAdmin && !isCreator && !isSelf) { - actions.append( - h('button', { - class: 'px-2 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-[10px] font-extrabold transition-colors', - title: 'Rétrograder élève', - onclick: () => changeRole(member.id, 'student', container, rerender), - }, '↓ Élève'), - ) + if (memberIsAdmin && !member.isCreator && !isSelf) { + actions.append(h('button', { + class: 'px-2 py-1 rounded-lg bg-rose-500/10 text-rose-400 text-[10px] font-extrabold', + onclick: () => changeRole(member.id, 'student', rerender), + }, '↓ Élève')) } return h( 'div', { class: 'flex items-center justify-between gap-2 bg-slate-900/40 border border-slate-700/40 rounded-xl px-3 py-2' }, - h( - 'div', - { class: 'flex items-center gap-2 min-w-0' }, + h('div', { class: 'flex items-center gap-2 min-w-0' }, h('span', { class: 'w-7 h-7 rounded-full bg-slate-700/60 flex items-center justify-center text-[10px] font-black text-slate-300 shrink-0' }, member.nickname.charAt(0).toUpperCase()), h('span', { class: 'text-xs font-bold text-slate-200 truncate' }, member.nickname), @@ -280,21 +442,142 @@ function memberRow( ) } -async function changeRole( - memberId: number, - role: Role, - container: HTMLElement, - rerender: () => void, -): Promise { +async function changeRole(memberId: number, role: Role, rerender: () => void): Promise { try { await api.setMemberRole(memberId, role) showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success') rerender() } catch (error) { - const msg = error instanceof Error ? error.message : 'Erreur' - showToast(msg, 'reject') - // Recharger la liste pour refléter l'état réel - const selfMember = getState().session?.member ?? null - await loadMembers(container, selfMember, rerender) + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') } } + +// === FORMULAIRE ÉVALUATION ==================================================== + +function openExamForm(exam: Exam | undefined, rerender: () => void): void { + const overlay = h('div', { + class: 'fixed inset-0 z-[95] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center', + }) + const close = (): void => overlay.remove() + const editing = exam !== undefined + + const now = new Date() + now.setMinutes(0, 0, 0) + now.setHours(Math.max(8, now.getHours() + 1)) + const isoDay = (dt: Date): string => + `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` + + const subject = h('input', { class: inputClass, value: exam?.subject ?? '', placeholder: 'Mathématiques' }) + const teacher = h('input', { class: inputClass, value: exam?.teacher ?? '', placeholder: 'M. Roussel' }) + const room = h('input', { class: inputClass, value: exam?.room ?? '', placeholder: 'B204' }) + const chapter = h('input', { class: inputClass, value: exam?.chapter ?? '', placeholder: 'Chapitre' }) + const day = h('input', { type: 'date', class: inputClass, value: exam ? isoDay(new Date(exam.startsAt)) : isoDay(now) }) + const time = h('input', { + type: 'time', + class: inputClass, + value: exam + ? `${String(new Date(exam.startsAt).getHours()).padStart(2, '0')}:${String(new Date(exam.startsAt).getMinutes()).padStart(2, '0')}` + : `${String(now.getHours()).padStart(2, '0')}:00`, + }) + const duration = h('input', { type: 'number', class: inputClass, value: String(exam?.durationMin ?? 60), min: '5', max: '480', step: '5' }) + const weight = h('input', { type: 'number', class: inputClass, value: String(exam?.weight ?? 2), min: '0', max: '20', step: '0.5' }) + const note = h('textarea', { class: `${inputClass} min-h-[60px] resize-none`, placeholder: 'Note…' }) + note.value = exam?.note ?? '' + + let kind: WeatherKind = exam?.kind ?? 'orage' + let kindOverride = false + + const kindPreview = h('div', { class: 'flex flex-wrap gap-1.5' }) + const renderKinds = (): void => { + kindPreview.replaceChildren( + ...WEATHER_KINDS.map((k) => { + const meta = WEATHER[k] + const active = kind === k + return h('button', { + type: 'button', + class: `px-2.5 py-1.5 rounded-xl text-[11px] font-extrabold border ${active ? meta.badge : 'bg-slate-800/60 border-slate-700 text-slate-400'}`, + onclick: () => { kind = k; kindOverride = true; renderKinds() }, + }, `${meta.icon} ${meta.label}`) + }), + ) + } + + weight.addEventListener('input', () => { + const v = Number(weight.value) + if (!Number.isFinite(v)) return + if (!kindOverride) { kind = kindFromWeight(v); renderKinds() } + }) + renderKinds() + + const errorBox = h('p', { class: 'hidden text-xs font-bold text-rose-300 bg-rose-500/10 border border-rose-500/25 rounded-xl px-3 py-2' }) + const showError = (msg: string): void => { errorBox.textContent = msg; errorBox.classList.remove('hidden') } + + const submitBtn = h('button', { + class: 'flex-1 bg-sky-500 text-slate-950 text-sm font-extrabold py-3 rounded-xl', + onclick: async () => { + if (!subject.value.trim()) { showError('La matière est obligatoire.'); return } + const startsAt = new Date(`${day.value}T${time.value || '08:00'}:00`) + if (Number.isNaN(startsAt.getTime())) { showError('Date invalide.'); return } + const dur = Number(duration.value) + if (!Number.isFinite(dur) || dur < 5) { showError('Durée minimum : 5 min.'); return } + submitBtn.disabled = true + submitBtn.classList.add('opacity-60') + try { + await saveExam({ + subject: subject.value.trim(), + teacher: teacher.value.trim() || null, + room: room.value.trim() || null, + chapter: chapter.value.trim() || null, + startsAt: startsAt.toISOString(), + durationMin: dur, + weight: Number(weight.value) || 0, + kind, + note: note.value.trim() || null, + }, exam?.id) + showToast(editing ? 'Évaluation mise à jour' : 'Évaluation ajoutée', 'success') + close() + rerender() + } catch (error) { + showError(error instanceof Error ? error.message : 'Erreur') + submitBtn.disabled = false + submitBtn.classList.remove('opacity-60') + } + }, + }, editing ? 'Enregistrer' : 'Ajouter') + + const panel = h( + 'div', + { class: 'w-full sm:max-w-md bg-slate-900 border border-slate-700 rounded-t-[2rem] sm:rounded-[2rem] p-5 max-h-[90vh] overflow-y-auto no-scrollbar shadow-2xl' }, + h('div', { class: 'flex items-start justify-between gap-3 mb-4' }, + h('div', {}, + h('h2', { class: 'text-lg font-black text-slate-100' }, editing ? 'Modifier l’évaluation' : 'Nouvelle évaluation'), + h('p', { class: 'text-xs text-slate-400 font-semibold mt-0.5' }, 'Le statut météo découle du coefficient.'), + ), + h('button', { class: 'text-slate-400 text-lg', onclick: close }, h('i', { class: 'fa-solid fa-xmark' })), + ), + h('div', { class: 'space-y-3.5' }, + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Matière'), subject), + h('div', { class: 'grid grid-cols-2 gap-3' }, + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Enseignant'), teacher), + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Salle'), room)), + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Chapitre'), chapter), + h('div', { class: 'grid grid-cols-2 gap-3' }, + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Date'), day), + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Heure'), time)), + h('div', { class: 'grid grid-cols-2 gap-3' }, + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Durée (min)'), duration), + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Coefficient'), weight)), + h('div', {}, h('span', { class: labelClass }, 'Statut météo'), kindPreview), + h('label', { class: 'block' }, h('span', { class: labelClass }, 'Note'), note), + errorBox, + h('div', { class: 'flex gap-2 pt-1' }, + h('button', { class: 'bg-slate-800 border border-slate-700 text-slate-300 text-sm font-extrabold px-4 py-3 rounded-xl', onclick: close }, 'Annuler'), + submitBtn), + ), + ) + + overlay.append(panel) + overlay.addEventListener('click', (event) => { if (event.target === overlay) close() }) + document.body.append(overlay) + subject.focus() +}