v1.0.0 — redesign complet charte graphique V2
- 8 états météo : arc-en-ciel, ensoleillé, brumeux, nuageux, pluvieux, orageux, tempête, fin du monde - Fonts : Fredoka (titres) + Nunito (texte), palette V2 (bleu #3B82F6, orange #F59E0B, rouge #EF4444) - Nav flottante : Météo / Radar / Bilan / Paramètres (La Carte supprimée) - Thème clair / sombre (toggle dans Paramètres) - Icône app : papier quadrillé + 0 rouge manuscrit - Illustrations météo CSS pures (soleil, nuages, pluie, éclair, champignon nucléaire, arc-en-ciel) - Panel admin centralisé dans Paramètres (flash, classe, évaluations, météo, membres) - Bottom nav flottante avec safe-area pour ne plus toucher la nav système Android - Suppression des anciennes vues (cards.ts, map.ts, plus.ts, exam-form.ts)
|
|
@ -1,6 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Génère des icônes PWA simples (dégradé bleu + pastille blanche).
|
* Génère l'icône Météo Exam V2 : papier quadrillé + 0 rouge manuscrit.
|
||||||
* Usage : node scripts/gen-icons.mjs
|
|
||||||
*/
|
*/
|
||||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
|
@ -51,51 +50,73 @@ function encodePng(width, height, rgba) {
|
||||||
|
|
||||||
function drawIcon(size) {
|
function drawIcon(size) {
|
||||||
const buf = Buffer.alloc(size * size * 4)
|
const buf = Buffer.alloc(size * size * 4)
|
||||||
const c0 = [56, 189, 248]
|
const paperR = 248, paperG = 250, paperB = 252 // #F8FAFC
|
||||||
const c1 = [99, 102, 241]
|
const gridR = 185, gridG = 213, gridB = 246 // #B9D5F6
|
||||||
|
const redR = 239, redG = 68, redB = 68 // #EF4444
|
||||||
|
const lineR = 236, lineG = 73, lineB = 73 // #EC4949
|
||||||
const cx = size / 2
|
const cx = size / 2
|
||||||
const cy = size / 2
|
const cy = size / 2
|
||||||
const rInner = size * 0.3
|
const gridSize = size / 10
|
||||||
const rPad = size * 0.16
|
|
||||||
|
|
||||||
for (let y = 0; y < size; y++) {
|
for (let y = 0; y < size; y++) {
|
||||||
for (let x = 0; x < size; x++) {
|
for (let x = 0; x < size; x++) {
|
||||||
const i = (y * size + x) * 4
|
const i = (y * size + x) * 4
|
||||||
const dx = x - cx
|
|
||||||
const dy = y - cy
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
||||||
|
|
||||||
// Fond arrondi : on coupe les coins
|
// Fond papier
|
||||||
let transparent = false
|
let r = paperR, g = paperG, b = paperB, a = 255
|
||||||
if (x < rPad && y < rPad) transparent = Math.hypot(rPad - x, rPad - y) > rPad
|
|
||||||
else if (x >= size - rPad && y < rPad) transparent = Math.hypot(x - (size - rPad), rPad - y) > rPad
|
|
||||||
else if (x < rPad && y >= size - rPad) transparent = Math.hypot(rPad - x, y - (size - rPad)) > rPad
|
|
||||||
else if (x >= size - rPad && y >= size - rPad) transparent = Math.hypot(x - (size - rPad), y - (size - rPad)) > rPad
|
|
||||||
|
|
||||||
if (transparent) {
|
// Quadrillage
|
||||||
buf[i + 3] = 0
|
const gx = x % gridSize
|
||||||
continue
|
const gy = y % gridSize
|
||||||
|
const nearVx = gx < 1.5 || gx > gridSize - 1.5
|
||||||
|
const nearHy = gy < 1.5 || gy > gridSize - 1.5
|
||||||
|
if (nearVx || nearHy) {
|
||||||
|
r = gridR; g = gridG; b = gridB
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = (x + y) / (size * 2)
|
// Marge rouge verticale (style cahier)
|
||||||
let r = c0[0] + (c1[0] - c0[0]) * t
|
if (x < size * 0.08 && x > size * 0.05) {
|
||||||
let g = c0[1] + (c1[1] - c0[1]) * t
|
r = lineR; g = lineG; b = lineB
|
||||||
let b = c0[2] + (c1[2] - c0[2]) * t
|
|
||||||
|
|
||||||
if (dist < rInner) {
|
|
||||||
r = 255
|
|
||||||
g = 255
|
|
||||||
b = 255
|
|
||||||
} else if (dist < rInner + size * 0.025) {
|
|
||||||
r = Math.min(255, r + 50)
|
|
||||||
g = Math.min(255, g + 50)
|
|
||||||
b = Math.min(255, b + 50)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buf[i] = Math.round(r)
|
// Dessiner le "0" rouge (ellipse inclinée)
|
||||||
buf[i + 1] = Math.round(g)
|
const dx = (x - cx) / (size * 0.22)
|
||||||
buf[i + 2] = Math.round(b)
|
const dy = (y - cy) / (size * 0.3)
|
||||||
buf[i + 3] = 255
|
const dist0 = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
// Anneau du 0
|
||||||
|
if (dist0 > 0.7 && dist0 < 1.15) {
|
||||||
|
r = redR; g = redG; b = redB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rayons du soleil (petits traits autour du 0)
|
||||||
|
const angle = Math.atan2(y - cy, x - cx)
|
||||||
|
const rayDist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
|
||||||
|
const rayOuter = size * 0.42
|
||||||
|
const rayInner = size * 0.36
|
||||||
|
if (rayDist > rayInner && rayDist < rayOuter) {
|
||||||
|
// 8 rayons
|
||||||
|
const rayAngle = (Math.round(angle / (Math.PI / 4)) * Math.PI) / 4
|
||||||
|
const perpDist = Math.abs((x - cx) * Math.sin(rayAngle) - (y - cy) * Math.cos(rayAngle))
|
||||||
|
if (perpDist < size * 0.02) {
|
||||||
|
r = redR; g = redG; b = redB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soulignement double en bas
|
||||||
|
const underY1 = cy + size * 0.38
|
||||||
|
const underY2 = cy + size * 0.42
|
||||||
|
if (Math.abs(y - underY1) < size * 0.015 && x > cx - size * 0.3 && x < cx + size * 0.3) {
|
||||||
|
r = redR; g = redG; b = redB
|
||||||
|
}
|
||||||
|
if (Math.abs(y - underY2) < size * 0.015 && x > cx - size * 0.28 && x < cx + size * 0.32) {
|
||||||
|
r = redR; g = redG; b = redB
|
||||||
|
}
|
||||||
|
|
||||||
|
buf[i] = r
|
||||||
|
buf[i + 1] = g
|
||||||
|
buf[i + 2] = b
|
||||||
|
buf[i + 3] = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buf
|
return buf
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,128 @@
|
||||||
/**
|
/**
|
||||||
* Types et métadonnées partagés entre le serveur et le front.
|
* Types et métadonnées partagés entre le serveur et le front.
|
||||||
* Aucun import runtime ici : ce module est consommé tel quel par les deux côtés.
|
* Charte graphique V2 — 8 états météo.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Statut météorologique d'une évaluation. */
|
export type WeatherKind =
|
||||||
export type WeatherKind = 'soleil' | 'brouillard' | 'vent' | 'orage' | 'tempete'
|
| 'arcenciel' // aucune évaluation + événement spécial
|
||||||
|
| 'soleil' // aucune évaluation
|
||||||
|
| 'brouillard' // suspicion
|
||||||
|
| 'nuageux' // petite évaluation / QCM
|
||||||
|
| 'pluie' // évaluation classique
|
||||||
|
| 'orage' // DS / contrôle surveillé
|
||||||
|
| 'tempete' // bac blanc / examen blanc
|
||||||
|
| 'fin_du_monde' // vrai bac
|
||||||
|
|
||||||
export const WEATHER_KINDS: WeatherKind[] = ['soleil', 'brouillard', 'vent', 'orage', 'tempete']
|
export const WEATHER_KINDS: WeatherKind[] = [
|
||||||
|
'arcenciel', 'soleil', 'brouillard', 'nuageux', 'pluie', 'orage', 'tempete', 'fin_du_monde',
|
||||||
|
]
|
||||||
|
|
||||||
/** Du plus rassurant au plus menaçant. Sert aux tris et au calcul du statut d'une journée. */
|
|
||||||
export const SEVERITY: Record<WeatherKind, number> = {
|
export const SEVERITY: Record<WeatherKind, number> = {
|
||||||
|
arcenciel: 0,
|
||||||
soleil: 0,
|
soleil: 0,
|
||||||
brouillard: 1,
|
brouillard: 1,
|
||||||
vent: 2,
|
nuageux: 2,
|
||||||
orage: 3,
|
pluie: 3,
|
||||||
tempete: 4,
|
orage: 4,
|
||||||
|
tempete: 5,
|
||||||
|
fin_du_monde: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeatherMeta {
|
export interface WeatherMeta {
|
||||||
/** Nom affiché, ex. « Alerte Rouge ». */
|
|
||||||
label: string
|
label: string
|
||||||
/** Titre long pour les modales et la légende. */
|
|
||||||
title: string
|
title: string
|
||||||
icon: string
|
icon: string
|
||||||
/** Description pédagogique affichée dans la légende. */
|
|
||||||
hint: string
|
hint: string
|
||||||
/** Couleur d'accent en dur, pour les SVG et jauges. */
|
/** Couleur principale du statut (hex). */
|
||||||
hex: string
|
hex: string
|
||||||
/** Classes Tailwind de la pastille (fond / bordure / texte). */
|
|
||||||
chip: string
|
|
||||||
/** Classe de couleur du texte seule, pour les libellés sur fond neutre. */
|
|
||||||
accent: string
|
|
||||||
/** Classes Tailwind du badge plein. */
|
/** Classes Tailwind du badge plein. */
|
||||||
badge: string
|
badge: string
|
||||||
|
/** Classe de couleur du texte seule. */
|
||||||
|
accent: string
|
||||||
|
/** Gradient CSS pour les cartes hero / mini-arts. */
|
||||||
|
gradient: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WEATHER: Record<WeatherKind, WeatherMeta> = {
|
export const WEATHER: Record<WeatherKind, WeatherMeta> = {
|
||||||
|
arcenciel: {
|
||||||
|
label: 'Arc-en-ciel',
|
||||||
|
title: 'Arc-en-ciel',
|
||||||
|
icon: '🌈',
|
||||||
|
hint: 'Aucune évaluation + événement spécial : sortie, pas de cours…',
|
||||||
|
hex: '#3B82F6',
|
||||||
|
badge: 'bg-blue-500/15 text-blue-300 border-blue-400/30',
|
||||||
|
accent: 'text-blue-400',
|
||||||
|
gradient: 'linear-gradient(145deg,#1570EF,#7555D9)',
|
||||||
|
},
|
||||||
soleil: {
|
soleil: {
|
||||||
label: 'Grand Soleil',
|
label: 'Ensoleillé',
|
||||||
title: 'Grand Soleil',
|
title: 'Ensoleillé',
|
||||||
icon: '☀️',
|
icon: '☀️',
|
||||||
hint: 'Journée calme. Aucun examen répertorié ni suspecté.',
|
hint: 'Aucune évaluation aujourd’hui.',
|
||||||
hex: '#0284c7',
|
hex: '#FFD43B',
|
||||||
chip: 'bg-sky-500/10 border-sky-500/20 text-sky-400',
|
badge: 'bg-amber-400/15 text-amber-300 border-amber-400/30',
|
||||||
accent: 'text-sky-400',
|
accent: 'text-amber-300',
|
||||||
badge: 'bg-sky-500/15 text-sky-300 border-sky-400/30',
|
gradient: 'linear-gradient(145deg,#1488FF,#55B8FF)',
|
||||||
},
|
},
|
||||||
brouillard: {
|
brouillard: {
|
||||||
label: 'Brouillard',
|
label: 'Brumeux',
|
||||||
title: 'Brouillard',
|
title: 'Brumeux',
|
||||||
icon: '🌫️',
|
icon: '🌫️',
|
||||||
hint: 'Zone d’incertitude. Rumeur ou sous-entendu d’un professeur.',
|
hint: 'Suspicion d’évaluation aujourd’hui.',
|
||||||
hex: '#7c3aed',
|
hex: '#9CA3AF',
|
||||||
chip: 'bg-purple-500/10 border-purple-500/20 text-purple-400',
|
badge: 'bg-slate-400/15 text-slate-300 border-slate-400/30',
|
||||||
accent: 'text-purple-400',
|
accent: 'text-slate-300',
|
||||||
badge: 'bg-purple-500/15 text-purple-300 border-purple-400/30',
|
gradient: 'linear-gradient(145deg,#A6B5C9,#7288A8)',
|
||||||
},
|
},
|
||||||
vent: {
|
nuageux: {
|
||||||
label: 'Coup de vent',
|
label: 'Nuageux',
|
||||||
title: 'Coup de vent',
|
title: 'Nuageux',
|
||||||
icon: '💨',
|
icon: '☁️',
|
||||||
hint: 'Interrogation surprise potentielle ou petit QCM, coefficient faible.',
|
hint: 'Petite évaluation, exposé ou QCM aujourd’hui.',
|
||||||
hex: '#d97706',
|
hex: '#60A5FA',
|
||||||
chip: 'bg-amber-500/10 border-amber-500/20 text-amber-400',
|
badge: 'bg-sky-400/15 text-sky-300 border-sky-400/30',
|
||||||
accent: 'text-amber-400',
|
accent: 'text-sky-400',
|
||||||
badge: 'bg-amber-500/15 text-amber-300 border-amber-400/30',
|
gradient: 'linear-gradient(145deg,#57B7FF,#2D86ED)',
|
||||||
|
},
|
||||||
|
pluie: {
|
||||||
|
label: 'Pluvieux',
|
||||||
|
title: 'Pluvieux',
|
||||||
|
icon: '🌧️',
|
||||||
|
hint: 'Évaluation classique aujourd’hui.',
|
||||||
|
hex: '#3B82F6',
|
||||||
|
badge: 'bg-blue-500/15 text-blue-300 border-blue-400/30',
|
||||||
|
accent: 'text-blue-400',
|
||||||
|
gradient: 'linear-gradient(145deg,#14589B,#092E64)',
|
||||||
},
|
},
|
||||||
orage: {
|
orage: {
|
||||||
label: 'Orage',
|
label: 'Orageux',
|
||||||
title: 'Orage / Pluie battante',
|
title: 'Orageux',
|
||||||
icon: '🌩️',
|
icon: '⛈️',
|
||||||
hint: 'Devoir surveillé (DS) confirmé ou évaluation de chapitre.',
|
hint: 'Devoir surveillé aujourd’hui.',
|
||||||
hex: '#e11d48',
|
hex: '#8B5CF6',
|
||||||
chip: 'bg-rose-500/10 border-rose-500/20 text-rose-400',
|
badge: 'bg-purple-500/15 text-purple-300 border-purple-400/30',
|
||||||
accent: 'text-rose-400',
|
accent: 'text-purple-400',
|
||||||
badge: 'bg-rose-500/15 text-rose-300 border-rose-400/30',
|
gradient: 'linear-gradient(145deg,#291B69,#152758)',
|
||||||
},
|
},
|
||||||
tempete: {
|
tempete: {
|
||||||
label: 'Alerte Rouge',
|
label: 'Tempête',
|
||||||
title: 'Alerte Rouge / Tempête',
|
title: 'Tempête',
|
||||||
icon: '🚨',
|
icon: '🌀',
|
||||||
hint: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.',
|
hint: 'Bac blanc / examen blanc.',
|
||||||
hex: '#dc2626',
|
hex: '#EF4444',
|
||||||
chip: 'bg-red-500/10 border-red-500/20 text-red-500',
|
|
||||||
accent: 'text-red-500',
|
|
||||||
badge: 'bg-red-500/15 text-red-300 border-red-400/30',
|
badge: 'bg-red-500/15 text-red-300 border-red-400/30',
|
||||||
|
accent: 'text-red-400',
|
||||||
|
gradient: 'radial-gradient(circle at 50% 50%,#FBAE45 0 5%,#F04464 6% 12%,#7E2BD5 13% 21%,#11194B 22% 36%,#F04464 37% 43%,#11194B 44%)',
|
||||||
|
},
|
||||||
|
fin_du_monde: {
|
||||||
|
label: 'Fin du monde',
|
||||||
|
title: 'Fin du monde',
|
||||||
|
icon: '💥',
|
||||||
|
hint: 'Vrai bac. Niveau maximal.',
|
||||||
|
hex: '#FF713D',
|
||||||
|
badge: 'bg-orange-500/15 text-orange-300 border-orange-400/30',
|
||||||
|
accent: 'text-orange-400',
|
||||||
|
gradient: 'linear-gradient(#281846,#B33C33 55%,#F59337)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,28 +130,24 @@ export function isWeatherKind(value: unknown): value is WeatherKind {
|
||||||
return typeof value === 'string' && (WEATHER_KINDS as string[]).includes(value)
|
return typeof value === 'string' && (WEATHER_KINDS as string[]).includes(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Statut par défaut déduit du coefficient, pour pré-remplir le formulaire d'ajout.
|
|
||||||
* 0 = rien de prévu, 1 = interro surprise, 2-3 = DS, 4+ = alerte rouge.
|
|
||||||
*/
|
|
||||||
export function kindFromWeight(weight: number): WeatherKind {
|
export function kindFromWeight(weight: number): WeatherKind {
|
||||||
if (weight <= 0) return 'soleil'
|
if (weight <= 0) return 'soleil'
|
||||||
if (weight < 2) return 'vent'
|
if (weight < 1) return 'nuageux'
|
||||||
|
if (weight < 2) return 'pluie'
|
||||||
if (weight < 4) return 'orage'
|
if (weight < 4) return 'orage'
|
||||||
return 'tempete'
|
if (weight < 5) return 'tempete'
|
||||||
|
return 'fin_du_monde'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Les coefficients proposés dans le formulaire, avec leur statut déduit. */
|
|
||||||
export const WEIGHT_PRESETS: { value: number; label: string }[] = [
|
export const WEIGHT_PRESETS: { value: number; label: string }[] = [
|
||||||
{ value: 0, label: '0 — coursework' },
|
{ value: 0, label: '0 — rien' },
|
||||||
{ value: 1, label: '1 — interro' },
|
{ value: 1, label: '1 — QCM / interro' },
|
||||||
{ value: 2, label: '2 — contrôle' },
|
{ value: 2, label: '2 — contrôle' },
|
||||||
{ value: 3, label: '3 — DS' },
|
{ value: 3, label: '3 — DS' },
|
||||||
{ value: 4, label: '4 — examen blanc' },
|
{ value: 4, label: '4 — examen blanc' },
|
||||||
{ value: 5, label: '5 — épreuve commune' },
|
{ value: 5, label: '5 — épreuve commune' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Statut le plus sévère d'une journée ; `soleil` si la journée est vide. */
|
|
||||||
export function dominantKind(exams: { kind: WeatherKind }[]): WeatherKind {
|
export function dominantKind(exams: { kind: WeatherKind }[]): WeatherKind {
|
||||||
let best: WeatherKind = 'soleil'
|
let best: WeatherKind = 'soleil'
|
||||||
for (const exam of exams) {
|
for (const exam of exams) {
|
||||||
|
|
@ -131,10 +165,8 @@ export interface Exam {
|
||||||
teacher: string | null
|
teacher: string | null
|
||||||
room: string | null
|
room: string | null
|
||||||
chapter: string | null
|
chapter: string | null
|
||||||
/** ISO 8601 UTC. */
|
|
||||||
startsAt: string
|
startsAt: string
|
||||||
durationMin: number
|
durationMin: number
|
||||||
/** Coefficient : alimente la future « pression atmosphérique ». */
|
|
||||||
weight: number
|
weight: number
|
||||||
kind: WeatherKind
|
kind: WeatherKind
|
||||||
note: string | null
|
note: string | null
|
||||||
|
|
@ -146,7 +178,6 @@ export interface ClassInfo {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
level: string | null
|
level: string | null
|
||||||
/** Message d'annonce du jour (flash info), lu par tous les membres. */
|
|
||||||
flashMessage: string | null
|
flashMessage: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,11 +186,9 @@ export interface Member {
|
||||||
classId: number
|
classId: number
|
||||||
nickname: string
|
nickname: string
|
||||||
role: Role
|
role: Role
|
||||||
/** true = créateur de la classe (ne peut pas être rétrogradé). */
|
|
||||||
isCreator: boolean
|
isCreator: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Charge utile de `GET /api/me`, renvoyée aussi à la création et à l'adhésion. */
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
member: Member
|
member: Member
|
||||||
klass: ClassInfo
|
klass: ClassInfo
|
||||||
|
|
@ -169,7 +198,6 @@ export interface ApiError {
|
||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Override météo posé par l'admin pour un jour donné (clé locale AAAA-MM-JJ). */
|
|
||||||
export interface DayWeather {
|
export interface DayWeather {
|
||||||
classId: number
|
classId: number
|
||||||
dayKey: string
|
dayKey: string
|
||||||
|
|
@ -177,15 +205,12 @@ export interface DayWeather {
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flash info : message quotidien rédigé par l'admin. */
|
|
||||||
export interface FlashInfo {
|
export interface FlashInfo {
|
||||||
classId: number
|
classId: number
|
||||||
/** null = pas de message publié. */
|
|
||||||
message: string | null
|
message: string | null
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payloads émis par le flux SSE. */
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: 'exam.created'; exam: Exam }
|
| { type: 'exam.created'; exam: Exam }
|
||||||
| { type: 'exam.updated'; exam: Exam }
|
| { type: 'exam.updated'; exam: Exam }
|
||||||
|
|
@ -198,5 +223,4 @@ export type StreamEvent =
|
||||||
|
|
||||||
export const CLASS_CODE_LENGTH = 6
|
export const CLASS_CODE_LENGTH = 6
|
||||||
|
|
||||||
/** Version de l'application. Incrémentée à chaque release APK. */
|
export const APP_VERSION = '1.0.0'
|
||||||
export const APP_VERSION = '0.5.0'
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 10 KiB |
|
|
@ -1,40 +1,29 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr" class="h-full">
|
<html lang="fr" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#020617" />
|
<meta name="theme-color" content="#0F172A" />
|
||||||
<title>Météo Exam</title>
|
<title>Météo Exam</title>
|
||||||
|
|
||||||
<!-- PWA -->
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Météo Exam" />
|
<meta name="apple-mobile-web-app-title" content="Météo Exam" />
|
||||||
<meta name="description" content="Le calendrier des évaluations scolaires, en bulletin météo interactif." />
|
|
||||||
|
|
||||||
<!-- Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@600;700&family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-950 text-slate-100 font-sans h-full">
|
<body>
|
||||||
<div id="app" class="flex items-center justify-center min-h-screen"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
||||||
// Si une nouvelle version du SW est disponible, on l'active.
|
|
||||||
reg.addEventListener('updatefound', () => {
|
reg.addEventListener('updatefound', () => {
|
||||||
const nw = reg.installing
|
const nw = reg.installing
|
||||||
if (!nw) return
|
if (!nw) return
|
||||||
|
|
@ -44,7 +33,6 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Quand un nouveau SW prend le contrôle, on recharge pour servir la nouvelle version.
|
|
||||||
let refreshing = false
|
let refreshing = false
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
if (refreshing) return
|
if (refreshing) return
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
|
@ -1,20 +1,30 @@
|
||||||
export function h<K extends keyof HTMLElementTagNameMap>(
|
export function h<K extends keyof HTMLElementTagNameMap>(
|
||||||
tag: K,
|
tag: K,
|
||||||
props: Partial<HTMLElementTagNameMap[K]> & { class?: string; dataset?: Record<string, string> } = {},
|
props: Record<string, unknown> = {},
|
||||||
...children: (Node | string | null | undefined | false)[]
|
...children: (Node | string | null | undefined | false)[]
|
||||||
): HTMLElementTagNameMap[K] {
|
): HTMLElementTagNameMap[K] {
|
||||||
const el = document.createElement(tag)
|
const el = document.createElement(tag)
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(props)) {
|
for (const [key, value] of Object.entries(props)) {
|
||||||
if (value === undefined || value === null) continue
|
if (value === undefined || value === null || value === false) continue
|
||||||
if (key === 'class') el.className = String(value)
|
|
||||||
else if (key === 'dataset') Object.assign(el.dataset, value)
|
if (key === 'class') {
|
||||||
else if (key === 'style') Object.assign(el.style, value)
|
el.className = String(value)
|
||||||
else if (key.startsWith('on') && typeof value === 'function') {
|
} else if (key === 'dataset') {
|
||||||
|
Object.assign(el.dataset, value)
|
||||||
|
} else if (key === 'style') {
|
||||||
|
// Accepte une string CSS ou un objet.
|
||||||
|
if (typeof value === 'string') el.setAttribute('style', value)
|
||||||
|
else Object.assign(el.style, value)
|
||||||
|
} else if (key === 'html') {
|
||||||
|
el.innerHTML = String(value)
|
||||||
|
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||||
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
|
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
|
||||||
} else if (key === 'html') el.innerHTML = String(value)
|
} else if (key in el) {
|
||||||
else if (key in el) (el as unknown as Record<string, unknown>)[key] = value
|
;(el as unknown as Record<string, unknown>)[key] = value
|
||||||
else el.setAttribute(key, String(value))
|
} else {
|
||||||
|
el.setAttribute(key, String(value))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
|
|
@ -24,23 +34,6 @@ export function h<K extends keyof HTMLElementTagNameMap>(
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clear = (el: HTMLElement): void => {
|
|
||||||
el.replaceChildren()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remplace les enfants d'un conteneur en une seule passe. */
|
|
||||||
export const mount = (el: HTMLElement, ...children: Node[]): void => {
|
export const mount = (el: HTMLElement, ...children: Node[]): void => {
|
||||||
el.replaceChildren(...children)
|
el.replaceChildren(...children)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Échappe une valeur destinée à `innerHTML` (libellés de 서버, notes libres).
|
|
||||||
* Toutes les données de classe sont saisies par des élèves : rien n'est fiable.
|
|
||||||
*/
|
|
||||||
export const escapeHtml = (value: string): string =>
|
|
||||||
value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,100 @@
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
import { h, mount } from './dom'
|
import { h, mount } from './dom'
|
||||||
import { bootstrap, getState, isAdmin, subscribe } from './store'
|
import { bootstrap, getState, subscribe } from './store'
|
||||||
import { renderJoin } from './views/join'
|
import { renderJoin } from './views/join'
|
||||||
import { renderWeek } from './views/week'
|
import { renderWeek } from './views/week'
|
||||||
import { renderRadar } from './views/radar'
|
import { renderRadar } from './views/radar'
|
||||||
import { renderMap } from './views/map'
|
import { renderJournal } from './views/journal'
|
||||||
import { renderPlus } from './views/plus'
|
import { renderSettings } from './views/settings'
|
||||||
import { getBaseUrl } from './config'
|
import { getBaseUrl } from './config'
|
||||||
import { checkForUpdate } from './update'
|
import { checkForUpdate } from './update'
|
||||||
import { showUpdateModal } from './update-modal'
|
import { showUpdateModal } from './update-modal'
|
||||||
|
|
||||||
type Tab = 'meteo' | 'radar' | 'carte' | 'plus'
|
type Tab = 'meteo' | 'radar' | 'bilan' | 'parametres'
|
||||||
|
|
||||||
const app = document.getElementById('app')!
|
const app = document.getElementById('app')!
|
||||||
let activeTab: Tab = 'meteo'
|
let activeTab: Tab = 'meteo'
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||||
{ id: 'meteo', label: 'Météo', icon: 'fa-cloud-sun' },
|
{ id: 'meteo', label: 'Météo', icon: 'cloud-sun' },
|
||||||
{ id: 'radar', label: 'Radar', icon: 'fa-satellite-dish' },
|
{ id: 'radar', label: 'Radar', icon: 'satellite-dish' },
|
||||||
{ id: 'carte', label: 'La Carte', icon: 'fa-map-marked-alt' },
|
{ id: 'bilan', label: 'Bilan', icon: 'journal-bookmark' },
|
||||||
{ id: 'plus', label: 'Plus', icon: 'fa-ellipsis-h' },
|
{ id: 'parametres', label: 'Paramètres', icon: 'gear' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function svgIcon(name: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'cloud-sun': '<path d="M4 15a4 4 0 0 1 4-4 5 5 0 0 1 9.6 1.8A3.2 3.2 0 1 1 18 19H7a3 3 0 0 1-3-4Z"/><path d="M8 5V3M4.5 6.5 3 5M12 6l1.5-1.5"/>',
|
||||||
|
'satellite-dish': '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><path d="M12 12 18 6M12 2v2M12 20v2M2 12h2M20 12h2"/>',
|
||||||
|
'journal-bookmark': '<path d="M6 3h12v18H6z"/><path d="M9 7h6M9 11h6M9 15h4"/>',
|
||||||
|
'gear': '<circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.4 1A7 7 0 0 0 15 6l-.3-2.6h-4L10.4 6A7 7 0 0 0 8.9 7.1l-2.4-1-2 3.4 2 1.5a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.4-1A7 7 0 0 0 10.4 18l.3 2.6h4L15 18a7 7 0 0 0 1.5-1.1l2.4 1 2-3.4-2-1.5c.1-.3.1-.7.1-1Z"/>',
|
||||||
|
}
|
||||||
|
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${icons[name] ?? ''}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
function renderApp(): void {
|
function renderApp(): void {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
|
|
||||||
// Écran d'accueil : pas de session valide.
|
// Écran d'accueil
|
||||||
if (!state.session) {
|
if (!state.session) {
|
||||||
const container = h('div', { class: 'mobile-container' })
|
const container = h('div', { class: 'mobile-container' })
|
||||||
const main = h('main', { class: 'flex-1 overflow-y-auto no-scrollbar p-5' })
|
const main = h('main', { class: 'app-main' })
|
||||||
renderJoin(main, renderApp)
|
renderJoin(main, renderApp)
|
||||||
container.append(main)
|
container.append(main)
|
||||||
mount(app, container)
|
mount(app, container)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// App pleine : header + contenu + nav.
|
|
||||||
const container = h('div', { class: 'mobile-container' })
|
const container = h('div', { class: 'mobile-container' })
|
||||||
|
|
||||||
|
// Header
|
||||||
const header = h(
|
const header = h(
|
||||||
'header',
|
'header',
|
||||||
{ class: 'pt-8 pb-3 px-6 bg-slate-900/80 backdrop-blur-md flex justify-between items-center z-20 border-b border-slate-800/60' },
|
{ class: 'app-header' },
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'flex items-center gap-3' },
|
{ class: 'flex items-center gap-3' },
|
||||||
h('div', { class: 'w-9 h-9 rounded-2xl bg-gradient-to-tr from-sky-400 to-indigo-500 flex items-center justify-center text-white font-black shadow-md text-sm' }, 'ME'),
|
h('div', { class: 'app-mark paper-grid' }, h('div', { class: 'zero' }, '0')),
|
||||||
h('div', {},
|
h(
|
||||||
h('h1', { class: 'text-lg font-extrabold text-white tracking-tight leading-none' }, 'Météo Exam'),
|
'div',
|
||||||
h('p', { class: 'text-xs font-bold text-sky-400 mt-0.5' }, `${state.session.klass.name}${state.session.klass.level ? ` · ${state.session.klass.level}` : ''}`),
|
{},
|
||||||
|
h('h1', { class: 'font-bold text-lg leading-none' }, 'Météo Exam'),
|
||||||
|
h('p', { class: 'text-[10px] font-black tracking-widest uppercase mt-1', style: 'color:var(--blue2)' },
|
||||||
|
`${state.klass?.name ?? state.session.klass.name}${state.klass?.level ? ` • ${state.klass.level}` : ''}`),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'button',
|
||||||
{ class: 'flex items-center gap-2' },
|
{ class: 'live-btn', onclick: () => {} },
|
||||||
isAdmin()
|
h('span', { class: 'status-dot', style: 'background:var(--good)' }),
|
||||||
? h('span', { class: 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-extrabold bg-sky-500/10 text-sky-400 border border-sky-500/20' },
|
|
||||||
h('i', { class: 'fa-solid fa-user-shield mr-1' }), 'Admin')
|
|
||||||
: null,
|
|
||||||
h('span', { class: 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-extrabold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' },
|
|
||||||
h('span', { class: 'w-2 h-2 rounded-full bg-emerald-500 animate-ping mr-1.5' }),
|
|
||||||
'Direct'),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const main = h('main', { class: 'flex-1 overflow-y-auto no-scrollbar p-5 pb-28 relative' })
|
// Main content
|
||||||
|
const main = h('main', { class: 'app-main' })
|
||||||
const rerender = (): void => renderApp()
|
const rerender = (): void => renderApp()
|
||||||
|
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'meteo': renderWeek(main, rerender); break
|
case 'meteo': renderWeek(main, rerender); break
|
||||||
case 'radar': renderRadar(main, rerender); break
|
case 'radar': renderRadar(main, rerender); break
|
||||||
case 'carte': renderMap(main, rerender); break
|
case 'bilan': renderJournal(main, rerender); break
|
||||||
case 'plus': renderPlus(main, rerender); break
|
case 'parametres': renderSettings(main, rerender); break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bottom nav
|
||||||
const nav = h(
|
const nav = h(
|
||||||
'nav',
|
'nav',
|
||||||
{ class: 'absolute bottom-0 left-0 right-0 bg-slate-900/95 backdrop-blur-xl border-t border-slate-800 px-4 py-3 flex justify-around items-center z-30' },
|
{ class: 'bottom-nav' },
|
||||||
...TABS.map((tab) => {
|
...TABS.map((tab) => {
|
||||||
const active = tab.id === activeTab
|
const active = tab.id === activeTab
|
||||||
return h(
|
return h(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
class: `flex flex-col items-center gap-1 transition-colors ${active ? 'text-sky-400' : 'text-slate-500'}`,
|
class: active ? 'active' : '',
|
||||||
onclick: () => {
|
onclick: () => { activeTab = tab.id; renderApp() },
|
||||||
activeTab = tab.id
|
|
||||||
renderApp()
|
|
||||||
},
|
},
|
||||||
},
|
h('span', { html: svgIcon(tab.icon) }),
|
||||||
tab.id === 'radar'
|
h('span', {}, tab.label),
|
||||||
? h('div', { class: 'relative' },
|
|
||||||
h('i', { class: `fa-solid ${tab.icon} text-xl` }),
|
|
||||||
h('span', { class: 'absolute -top-1 -right-1 w-2 h-2 bg-rose-500 rounded-full' }))
|
|
||||||
: h('i', { class: `fa-solid ${tab.icon} text-xl` }),
|
|
||||||
h('span', { class: 'text-[10px] font-black tracking-wide' }, tab.label),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -101,18 +103,12 @@ function renderApp(): void {
|
||||||
mount(app, container)
|
mount(app, container)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bootstrapping -----------------------------------------------------------
|
|
||||||
|
|
||||||
subscribe(renderApp)
|
subscribe(renderApp)
|
||||||
|
|
||||||
void bootstrap().then(() => {
|
void bootstrap().then(() => {
|
||||||
renderApp()
|
renderApp()
|
||||||
console.log(`Météo Exam — v${__APP_VERSION__} — serveur : ${getBaseUrl()}`)
|
console.log(`Météo Exam — v${__APP_VERSION__} — serveur : ${getBaseUrl()}`)
|
||||||
|
|
||||||
// Vérification de mise à jour (Android natif uniquement).
|
|
||||||
void checkForUpdate().then((update) => {
|
void checkForUpdate().then((update) => {
|
||||||
if (update) {
|
if (update) showUpdateModal(update, () => renderApp())
|
||||||
showUpdateModal(update, () => renderApp())
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,583 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* Les classes Tailwind provenant de @meteo-exam/shared doivent être scannées
|
|
||||||
par Tailwind : elles ne sont pas dans les sources du package web. */
|
|
||||||
@source '../../shared/src';
|
@source '../../shared/src';
|
||||||
|
|
||||||
html,
|
/* === Charte graphique V2 ================================================= */
|
||||||
body {
|
:root {
|
||||||
|
--blue: #3B82F6;
|
||||||
|
--blue2: #60A5FA;
|
||||||
|
--fog: #9CA3AF;
|
||||||
|
--orange: #F59E0B;
|
||||||
|
--red: #EF4444;
|
||||||
|
--bg: #0F172A;
|
||||||
|
--surface: #17233A;
|
||||||
|
--surface2: #1E293B;
|
||||||
|
--border: #334155;
|
||||||
|
--text: #F8FAFC;
|
||||||
|
--muted: #A8B3C7;
|
||||||
|
--paper: #F8FAFC;
|
||||||
|
--ink: #10213D;
|
||||||
|
--grid: #B9D5F6;
|
||||||
|
--good: #22C55E;
|
||||||
|
--cyan: #22D3EE;
|
||||||
|
--purple: #8B5CF6;
|
||||||
|
--shadow: 0 20px 55px rgba(3, 9, 24, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #EAF3FF;
|
||||||
|
--surface: #FFFFFF;
|
||||||
|
--surface2: #F2F7FD;
|
||||||
|
--border: #D5E2F1;
|
||||||
|
--text: #10213D;
|
||||||
|
--muted: #66758A;
|
||||||
|
--shadow: 0 18px 45px rgba(37, 76, 126, .13);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #020617;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: radial-gradient(circle at 50% -20%, #244975 0, #0A1528 45%, #050B15 100%);
|
||||||
|
font-family: 'Nunito', system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-family: 'Nunito', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbars */
|
html[data-theme="light"] body {
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
background: linear-gradient(150deg, #E6F2FF, #F8FBFF);
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.no-scrollbar {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile phone container (même forme que le prototype) */
|
/* Titres */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone container */
|
||||||
.mobile-container {
|
.mobile-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 430px;
|
max-width: 430px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-height: 932px;
|
max-height: 932px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background-color: #0f172a;
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 450px) {
|
@media (min-width: 450px) {
|
||||||
.mobile-container {
|
.mobile-container {
|
||||||
height: 90vh;
|
height: 90vh;
|
||||||
border-radius: 48px;
|
border-radius: 42px;
|
||||||
margin-top: 5vh;
|
margin-top: 5vh;
|
||||||
border: 8px solid #1e293b;
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.7);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulseGlow {
|
/* Paper grid background (pour l'icône et certains éléments) */
|
||||||
0%,
|
.paper-grid {
|
||||||
100% {
|
background-color: var(--paper);
|
||||||
opacity: 0.8;
|
background-image:
|
||||||
transform: scale(1);
|
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||||
}
|
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||||
50% {
|
background-size: 18px 18px;
|
||||||
opacity: 1;
|
}
|
||||||
transform: scale(1.05);
|
|
||||||
|
/* Scrollbar cachée */
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.app-header {
|
||||||
|
padding: 22px 20px 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
.app-mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 15px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, .16);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-mark .zero {
|
||||||
|
font-family: 'Comic Sans MS', 'Bradley Hand', cursive;
|
||||||
|
color: #EF4444;
|
||||||
|
font-size: 35px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 46px;
|
||||||
|
text-align: center;
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-mark .zero::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 7px;
|
||||||
|
height: 3px;
|
||||||
|
background: #EF4444;
|
||||||
|
transform: rotate(-7deg);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main scroll area */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 18px 16px 120px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Section titles */
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 20px 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.surface-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 21px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day forecast row */
|
||||||
|
.day-row {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 19px;
|
||||||
|
padding: 11px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-row:active { transform: scale(.985); }
|
||||||
|
|
||||||
|
/* Mini art (CSS illustrations) */
|
||||||
|
.mini-art {
|
||||||
|
height: 47px;
|
||||||
|
border-radius: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom navigation flottante */
|
||||||
|
.bottom-nav {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
height: 78px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 25px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
padding: 7px;
|
||||||
|
box-shadow: 0 14px 30px rgba(0, 0, 0, .22);
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 900;
|
||||||
|
transition: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav button.active {
|
||||||
|
background: color-mix(in srgb, var(--blue) 16%, var(--surface2));
|
||||||
|
color: var(--blue2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.app-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
outline: none;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-input:focus {
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
border: 0;
|
||||||
|
background: var(--blue);
|
||||||
|
color: white;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active { transform: scale(.97); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active { transform: scale(.97); }
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.toggle {
|
||||||
|
width: 46px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #4B5B72;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
transition: .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on::after {
|
||||||
|
left: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero card */
|
||||||
|
.hero-card {
|
||||||
|
border-radius: 29px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
min-height: 255px;
|
||||||
|
padding: 20px;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 16px 38px rgba(59, 130, 246, .25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 75% 15%, rgba(255, 255, 255, .26), transparent 26%),
|
||||||
|
linear-gradient(transparent 68%, rgba(3, 10, 27, .18));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, .18);
|
||||||
|
backdrop-filter: blur(9px);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, .17);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot */
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS weather illustrations */
|
||||||
|
.art-sun { background: linear-gradient(145deg,#1488FF,#55B8FF); }
|
||||||
|
.art-fog { background: linear-gradient(145deg,#A6B5C9,#7288A8); }
|
||||||
|
.art-cloud { background: linear-gradient(145deg,#57B7FF,#2D86ED); }
|
||||||
|
.art-rain { background: linear-gradient(145deg,#14589B,#092E64); }
|
||||||
|
.art-storm { background: linear-gradient(145deg,#291B69,#152758); }
|
||||||
|
.art-hurricane { background: radial-gradient(circle at 50% 50%,#FBAE45 0 5%,#F04464 6% 12%,#7E2BD5 13% 21%,#11194B 22% 36%,#F04464 37% 43%,#11194B 44%); }
|
||||||
|
.art-nuke { background: linear-gradient(#281846,#B33C33 55%,#F59337); }
|
||||||
|
.art-rainbow { background: linear-gradient(145deg,#1570EF,#7555D9); }
|
||||||
|
|
||||||
|
.sun-shape {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: #FFD43B; border-radius: 50%;
|
||||||
|
position: absolute; left: 50%; top: 50%;
|
||||||
|
transform: translate(-50%,-50%);
|
||||||
|
box-shadow: 0 0 0 7px rgba(255,212,59,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-shape::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: -12px;
|
||||||
|
background: repeating-conic-gradient(from 0deg,#FFD43B 0 5deg,transparent 5deg 45deg);
|
||||||
|
-webkit-mask: radial-gradient(circle,transparent 0 17px,#000 18px);
|
||||||
|
mask: radial-gradient(circle,transparent 0 17px,#000 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-cloud {
|
||||||
|
position: absolute; width: 47px; height: 18px;
|
||||||
|
border-radius: 50px; background: #ECF6FF;
|
||||||
|
left: 50%; top: 54%; transform: translate(-50%,-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-cloud::before, .simple-cloud::after {
|
||||||
|
content: ''; position: absolute; border-radius: 50%; background: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-cloud::before { width: 25px; height: 25px; left: 7px; top: -12px; }
|
||||||
|
.simple-cloud::after { width: 20px; height: 20px; left: 25px; top: -8px; }
|
||||||
|
|
||||||
|
.fog-lines {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(transparent 18%,rgba(255,255,255,.92) 19% 25%,transparent 26% 42%,rgba(255,255,255,.66) 43% 49%,transparent 50% 66%,rgba(255,255,255,.45) 67% 73%,transparent 74%);
|
||||||
|
transform: skewX(-12deg) scale(.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rain-drops {
|
||||||
|
position: absolute; left: 23px; right: 20px; bottom: 5px; height: 20px;
|
||||||
|
background: repeating-linear-gradient(115deg,transparent 0 9px,#39C6F4 10px 13px,transparent 14px 22px);
|
||||||
|
transform: skewX(-10deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bolt-shape {
|
||||||
|
position: absolute; left: 51%; top: 55%;
|
||||||
|
transform: translate(-50%,-50%) skewX(-10deg);
|
||||||
|
width: 15px; height: 32px; background: #FFD43B;
|
||||||
|
clip-path: polygon(48% 0,100% 0,66% 40%,100% 40%,21% 100%,39% 56%,0 56%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mushroom-shape {
|
||||||
|
position: absolute; left: 50%; top: 50%;
|
||||||
|
transform: translate(-50%,-50%);
|
||||||
|
width: 34px; height: 30px; background: #FF8A3D;
|
||||||
|
border-radius: 50% 50% 40% 40%;
|
||||||
|
box-shadow: -18px 6px 0 -3px #FFA640, 18px 6px 0 -3px #FFA640, 0 -9px 0 3px #FFCE5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mushroom-shape::after {
|
||||||
|
content: ''; position: absolute; width: 11px; height: 20px;
|
||||||
|
background: #FF8A3D; left: 12px; top: 20px; border-radius: 0 0 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rainbow-shape {
|
||||||
|
position: absolute; left: 50%; top: 52%;
|
||||||
|
transform: translate(-50%,-50%); width: 205px; height: 110px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rainbow-arc {
|
||||||
|
position: absolute; left: 15px; right: 15px; top: 13px; height: 145px;
|
||||||
|
border-radius: 120px 120px 0 0;
|
||||||
|
background: radial-gradient(ellipse at center bottom,transparent 0 42px,#5B5BE7 43px 53px,#44C7F4 54px 64px,#33D17A 65px 75px,#FFD43B 76px 86px,#FF8A3D 87px 97px,#FF4C66 98px 108px,transparent 109px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-puff {
|
||||||
|
position: absolute; background: linear-gradient(#fff,#DCEBFF);
|
||||||
|
border-radius: 999px; filter: drop-shadow(0 6px 9px rgba(24,67,123,.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-puff::before, .cloud-puff::after {
|
||||||
|
content: ''; position: absolute; border-radius: 50%; background: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Certainty bar (radar) */
|
||||||
|
.certainty-bar {
|
||||||
|
height: 7px; background: var(--surface2); border-radius: 99px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certainty-bar span {
|
||||||
|
display: block; height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--orange), var(--good));
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legend grid */
|
||||||
|
.legend-grid {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grade badge (bilan) */
|
||||||
|
.grade-badge {
|
||||||
|
width: 55px; height: 55px; border-radius: 17px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-family: 'Fredoka', sans-serif; font-size: 18px;
|
||||||
|
background: #163F33; color: #5EE2A0; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-badge.bad { background: #4A2430; color: #FF8CA5; }
|
||||||
|
|
||||||
|
/* Theme buttons */
|
||||||
|
.theme-btns {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btns button {
|
||||||
|
border: 1px solid var(--border); background: var(--surface2);
|
||||||
|
padding: 12px; border-radius: 14px; font-weight: 900; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btns button.active {
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast-bar {
|
||||||
|
position: absolute; left: 50%; top: 80px;
|
||||||
|
transform: translate(-50%, -12px);
|
||||||
|
opacity: 0; background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
padding: 9px 12px; border-radius: 999px; font-size: 11px; font-weight: 900;
|
||||||
|
z-index: 50; pointer-events: none; transition: .22s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-bar.show { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes enter {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-enter { animation: enter .2s ease; }
|
||||||
|
|
||||||
|
/* Overlay modals */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 95;
|
||||||
|
background: rgba(2, 6, 23, .7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.modal-overlay { align-items: center; padding: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%; max-width: 430px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 2rem 2rem 0 0;
|
||||||
|
padding: 20px; max-height: 88vh; overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.modal-panel { border-radius: 2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area padding for Android system nav */
|
||||||
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
|
.bottom-nav {
|
||||||
|
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animate-pulse-glow {
|
|
||||||
animation: pulseGlow 3s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function compareVersions(a: string, b: string): number {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie si une mise à jour est disponible sur le serveur.
|
* Vérifie si une mise à jour est disponible sur le serveur.
|
||||||
* Seulement sur Android natif : la PWA iOS se met à jour en rechargeant.
|
* Utilise CapacitorHttp (natif) pour éviter les blocages WebView.
|
||||||
*/
|
*/
|
||||||
export async function checkForUpdate(): Promise<UpdateInfo | null> {
|
export async function checkForUpdate(): Promise<UpdateInfo | null> {
|
||||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
|
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'android') {
|
||||||
|
|
@ -35,12 +35,14 @@ export async function checkForUpdate(): Promise<UpdateInfo | null> {
|
||||||
const localVersion = __APP_VERSION__
|
const localVersion = __APP_VERSION__
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getBaseUrl()}/api/version`, {
|
const response = await CapacitorHttp.request({
|
||||||
|
url: `${getBaseUrl()}/api/version`,
|
||||||
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
})
|
})
|
||||||
if (!response.ok) return null
|
if (response.status !== 200) return null
|
||||||
|
|
||||||
const remote = (await response.json()) as UpdateInfo
|
const remote = response.data as UpdateInfo
|
||||||
if (compareVersions(remote.version, localVersion) <= 0) return null
|
if (compareVersions(remote.version, localVersion) <= 0) return null
|
||||||
|
|
||||||
const dismissed = localStorage.getItem('meteo-exam.dismissed-update')
|
const dismissed = localStorage.getItem('meteo-exam.dismissed-update')
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { WEATHER, type WeatherKind } from '@meteo-exam/shared'
|
|
||||||
import { h } from '../dom'
|
|
||||||
import { formatDuration, formatTime, formatWeight, longDate } from '../dates'
|
|
||||||
import type { Exam } from '@meteo-exam/shared'
|
|
||||||
|
|
||||||
/** Carte compacte d'une évaluation dans la liste. */
|
|
||||||
export function examCard(exam: Exam, onOpen: (exam: Exam) => void): HTMLElement {
|
|
||||||
const meta = WEATHER[exam.kind]
|
|
||||||
|
|
||||||
return h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
'w-full text-left bg-slate-800/80 border border-slate-700/60 rounded-2xl p-4 flex items-center justify-between gap-3.5 shadow-sm active:scale-[0.99] transition-transform',
|
|
||||||
onclick: () => onOpen(exam),
|
|
||||||
},
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex items-center gap-3.5 min-w-0' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: `w-12 h-12 shrink-0 rounded-2xl border flex items-center justify-center text-2xl ${meta.chip}` },
|
|
||||||
meta.icon,
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'min-w-0' },
|
|
||||||
h('p', { class: `text-xs font-extrabold uppercase tracking-wide truncate ${meta.accent}` },
|
|
||||||
`${formatTime(exam.startsAt)} · ${meta.label}`),
|
|
||||||
h('h4', { class: 'text-base font-bold text-slate-100 truncate' }, exam.subject),
|
|
||||||
h(
|
|
||||||
'p',
|
|
||||||
{ class: 'text-xs text-slate-400 font-semibold truncate' },
|
|
||||||
[exam.chapter, exam.room].filter(Boolean).join(' · ') || formatDuration(exam.durationMin),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'shrink-0 flex flex-col items-end gap-1.5' },
|
|
||||||
h(
|
|
||||||
'span',
|
|
||||||
{ class: 'px-2.5 py-1 rounded-lg bg-slate-700/50 text-slate-300 text-xs font-bold' },
|
|
||||||
exam.weight > 0 ? `Coeff ${formatWeight(exam.weight)}` : ' coursework',
|
|
||||||
),
|
|
||||||
h('span', { class: 'text-[10px] font-bold text-slate-500' }, formatDuration(exam.durationMin)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fiche détaillée dans la modale. */
|
|
||||||
export function examDetail(exam: Exam): HTMLElement {
|
|
||||||
const meta = WEATHER[exam.kind]
|
|
||||||
|
|
||||||
const row = (icon: string, label: string, value: string | null | undefined): HTMLElement | null => {
|
|
||||||
if (!value) return null
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex items-start gap-3 py-2.5' },
|
|
||||||
h('i', { class: `fa-solid ${icon} text-slate-500 w-4 text-center mt-0.5` }),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'min-w-0' },
|
|
||||||
h('p', { class: 'text-[10px] font-black uppercase tracking-wider text-slate-500' }, label),
|
|
||||||
h('p', { class: 'text-sm font-bold text-slate-200 break-words' }, value),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'space-y-1' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex items-center gap-3.5 pb-3' },
|
|
||||||
h('div', { class: `w-14 h-14 rounded-2xl border flex items-center justify-center text-3xl ${meta.chip}` }, meta.icon),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'min-w-0' },
|
|
||||||
h('h2', { class: 'text-xl font-black text-slate-100 leading-tight' }, exam.subject),
|
|
||||||
h('p', { class: `text-xs font-black uppercase tracking-wider mt-0.5 ${meta.accent}` }, meta.title),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
h('div', { class: 'h-px bg-slate-700/60' }),
|
|
||||||
row('fa-solid fa-user-tie', 'Enseignant', exam.teacher),
|
|
||||||
row('fa-solid fa-door-open', 'Salle', exam.room),
|
|
||||||
row('fa-solid fa-book-open', 'Chapitre', exam.chapter),
|
|
||||||
row('fa-solid fa-calendar-day', 'Date', longDate(new Date(exam.startsAt))),
|
|
||||||
row('fa-solid fa-clock', 'Heure', formatTime(exam.startsAt)),
|
|
||||||
row('fa-solid fa-hourglass-half', 'Durée', formatDuration(exam.durationMin)),
|
|
||||||
row('fa-solid fa-gauge-high', 'Coefficient', exam.weight > 0 ? formatWeight(exam.weight) : ' coursework'),
|
|
||||||
exam.note ? row('fa-solid fa-note-sticky', 'Note', exam.note) : null,
|
|
||||||
h(
|
|
||||||
'p',
|
|
||||||
{ class: 'text-xs text-slate-400 font-medium leading-relaxed pt-3 mt-1 border-t border-slate-700/60' },
|
|
||||||
meta.hint,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const weatherBadge = (kind: WeatherKind): HTMLElement =>
|
|
||||||
h('span', { class: `inline-block px-3 py-1 rounded-xl text-xs font-black uppercase tracking-wider border ${WEATHER[kind].badge}` },
|
|
||||||
`${WEATHER[kind].icon} ${WEATHER[kind].label}`)
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
import type { Exam, WeatherKind } from '@meteo-exam/shared'
|
|
||||||
import { WEATHER, WEATHER_KINDS, WEIGHT_PRESETS, kindFromWeight } from '@meteo-exam/shared'
|
|
||||||
import { h } from '../dom'
|
|
||||||
import { isoDayInput } from '../dates'
|
|
||||||
import { saveExam, setError } from '../store'
|
|
||||||
import { showToast } from '../toast'
|
|
||||||
|
|
||||||
interface Draft {
|
|
||||||
subject: string
|
|
||||||
teacher: string
|
|
||||||
room: string
|
|
||||||
chapter: string
|
|
||||||
day: string
|
|
||||||
time: string
|
|
||||||
durationMin: number
|
|
||||||
weight: number
|
|
||||||
kind: WeatherKind
|
|
||||||
note: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors'
|
|
||||||
|
|
||||||
const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5'
|
|
||||||
|
|
||||||
function field(label: string, control: HTMLElement, hint?: string): HTMLElement {
|
|
||||||
return h(
|
|
||||||
'label',
|
|
||||||
{ class: 'block' },
|
|
||||||
h('span', { class: labelClass }, label),
|
|
||||||
control,
|
|
||||||
hint ? h('span', { class: 'block text-[10px] text-slate-600 font-semibold mt-1' }, hint) : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function draftFrom(exam: Exam | undefined): Draft {
|
|
||||||
if (exam) {
|
|
||||||
const date = new Date(exam.startsAt)
|
|
||||||
return {
|
|
||||||
subject: exam.subject,
|
|
||||||
teacher: exam.teacher ?? '',
|
|
||||||
room: exam.room ?? '',
|
|
||||||
chapter: exam.chapter ?? '',
|
|
||||||
day: isoDayInput(date),
|
|
||||||
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
|
|
||||||
durationMin: exam.durationMin,
|
|
||||||
weight: exam.weight,
|
|
||||||
kind: exam.kind,
|
|
||||||
note: exam.note ?? '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const now = new Date()
|
|
||||||
now.setMinutes(0, 0, 0)
|
|
||||||
now.setHours(Math.max(8, now.getHours() + 1))
|
|
||||||
return {
|
|
||||||
subject: '',
|
|
||||||
teacher: '',
|
|
||||||
room: '',
|
|
||||||
chapter: '',
|
|
||||||
day: isoDayInput(now),
|
|
||||||
time: `${String(now.getHours()).padStart(2, '0')}:00`,
|
|
||||||
durationMin: 60,
|
|
||||||
weight: 2,
|
|
||||||
kind: 'orage',
|
|
||||||
note: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeOverlay(overlay: HTMLElement): void {
|
|
||||||
overlay.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Modale d'ajout / modification. `exam` absent = création. */
|
|
||||||
export function openExamForm(exam: Exam | undefined, onSaved: () => void): void {
|
|
||||||
const draft = draftFrom(exam)
|
|
||||||
const editing = exam !== undefined
|
|
||||||
|
|
||||||
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 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 = (message: string): void => {
|
|
||||||
errorBox.textContent = message
|
|
||||||
errorBox.classList.remove('hidden')
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Champs ---------------------------------------------------------------
|
|
||||||
const subject = h('input', { class: inputClass, value: draft.subject, placeholder: 'Mathématiques' })
|
|
||||||
const teacher = h('input', { class: inputClass, value: draft.teacher, placeholder: 'M. Roussel' })
|
|
||||||
const room = h('input', { class: inputClass, value: draft.room, placeholder: 'B204' })
|
|
||||||
const chapter = h('input', { class: inputClass, value: draft.chapter, placeholder: 'Chapitre ou thème' })
|
|
||||||
const day = h('input', { type: 'date', class: inputClass, value: draft.day })
|
|
||||||
const time = h('input', { type: 'time', class: inputClass, value: draft.time })
|
|
||||||
const note = h('textarea', { class: `${inputClass} min-h-[70px] resize-none`, placeholder: 'Circonscription, calculatrice autorisée…' })
|
|
||||||
note.value = draft.note
|
|
||||||
|
|
||||||
const duration = h('input', { type: 'number', class: inputClass, value: String(draft.durationMin), min: '5', max: '480', step: '5' })
|
|
||||||
const weight = h('input', { type: 'number', class: inputClass, value: String(draft.weight), min: '0', max: '20', step: '0.5' })
|
|
||||||
|
|
||||||
// Le statut découle du coefficient tant qu'on ne le surcharge pas à la main.
|
|
||||||
let kindOverride = false
|
|
||||||
const kindPreview = h('div', { class: 'flex flex-wrap gap-1.5' })
|
|
||||||
|
|
||||||
const renderKinds = (): void => {
|
|
||||||
kindPreview.replaceChildren(
|
|
||||||
...WEATHER_KINDS.map((kind) => {
|
|
||||||
const active = draft.kind === kind
|
|
||||||
return h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
type: 'button',
|
|
||||||
class: `px-2.5 py-1.5 rounded-xl text-[11px] font-extrabold border transition-colors ${
|
|
||||||
active ? WEATHER[kind].badge : 'bg-slate-800/60 border-slate-700 text-slate-400'
|
|
||||||
}`,
|
|
||||||
onclick: () => {
|
|
||||||
draft.kind = kind
|
|
||||||
kindOverride = true
|
|
||||||
renderKinds()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
`${WEATHER[kind].icon} ${WEATHER[kind].label}`,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
weight.addEventListener('input', () => {
|
|
||||||
const value = Number(weight.value)
|
|
||||||
if (!Number.isFinite(value)) return
|
|
||||||
draft.weight = value
|
|
||||||
if (!kindOverride) {
|
|
||||||
draft.kind = kindFromWeight(value)
|
|
||||||
renderKinds()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
renderKinds()
|
|
||||||
|
|
||||||
// --- Actions --------------------------------------------------------------
|
|
||||||
const submit = async (): Promise<void> => {
|
|
||||||
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 ou heure invalide.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const durationMin = Number(duration.value)
|
|
||||||
if (!Number.isFinite(durationMin) || durationMin < 5) {
|
|
||||||
showError('Durée minimum : 5 minutes.')
|
|
||||||
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,
|
|
||||||
weight: Number(weight.value) || 0,
|
|
||||||
kind: draft.kind,
|
|
||||||
note: note.value.trim() || null,
|
|
||||||
},
|
|
||||||
exam?.id,
|
|
||||||
)
|
|
||||||
showToast(editing ? 'Évaluation mise à jour' : 'Évaluation ajoutée', 'success')
|
|
||||||
closeOverlay(overlay)
|
|
||||||
onSaved()
|
|
||||||
} catch (error) {
|
|
||||||
setError(error instanceof Error ? error.message : 'Enregistrement impossible')
|
|
||||||
showError(error instanceof Error ? error.message : 'Enregistrement impossible')
|
|
||||||
submitBtn.disabled = false
|
|
||||||
submitBtn.classList.remove('opacity-60')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitBtn = h(
|
|
||||||
'button',
|
|
||||||
{ class: 'flex-1 bg-sky-500 text-slate-950 text-sm font-extrabold py-3 rounded-xl active:scale-95 transition-transform', onclick: submit },
|
|
||||||
editing ? 'Enregistrer' : 'Ajouter au bulletin',
|
|
||||||
)
|
|
||||||
|
|
||||||
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 hover:text-slate-200 text-lg', onclick: () => closeOverlay(overlay) },
|
|
||||||
h('i', { class: 'fa-solid fa-xmark' })),
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'space-y-3.5' },
|
|
||||||
field('Matière', subject),
|
|
||||||
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Enseignant', teacher), field('Salle', room)),
|
|
||||||
field('Chapitre', chapter),
|
|
||||||
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Date', day), field('Heure', time)),
|
|
||||||
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Durée (min)', duration), field('Coefficient', weight)),
|
|
||||||
field(
|
|
||||||
'Statut météo',
|
|
||||||
kindPreview,
|
|
||||||
kindOverride ? 'Statut forcé manuellement.' : WEIGHT_PRESETS.find((p) => p.value === draft.weight)?.label,
|
|
||||||
),
|
|
||||||
field('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 active:scale-95 transition-transform', onclick: () => closeOverlay(overlay) },
|
|
||||||
'Annuler'),
|
|
||||||
submitBtn,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
overlay.append(panel)
|
|
||||||
overlay.addEventListener('click', (event) => {
|
|
||||||
if (event.target === overlay) closeOverlay(overlay)
|
|
||||||
})
|
|
||||||
document.body.append(overlay)
|
|
||||||
subject.focus()
|
|
||||||
}
|
|
||||||
17
web/src/views/journal.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { h, mount } from '../dom'
|
||||||
|
|
||||||
|
export function renderJournal(root: HTMLElement, _rerender: () => void): void {
|
||||||
|
mount(
|
||||||
|
root,
|
||||||
|
h('div', { class: 'tab-enter' },
|
||||||
|
h('div', { class: 'section-title', style: 'margin-top:0' },
|
||||||
|
h('h3', {}, 'Bilan / Journal'),
|
||||||
|
h('span', {}, 'évaluations passées'),
|
||||||
|
),
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('p', { style: 'font-size:12px;color:var(--muted);font-weight:700' },
|
||||||
|
'Les évaluations passées et les notes arriveront ici dans une prochaine version.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
import { WEATHER } from '@meteo-exam/shared'
|
|
||||||
import { h, mount } from '../dom'
|
|
||||||
|
|
||||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
||||||
|
|
||||||
function svgEl<K extends keyof SVGElementTagNameMap>(
|
|
||||||
tag: K,
|
|
||||||
attrs: Record<string, string | number> = {},
|
|
||||||
): SVGElementTagNameMap[K] {
|
|
||||||
const el = document.createElementNS(SVG_NS, tag)
|
|
||||||
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value))
|
|
||||||
return el
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Region {
|
|
||||||
id: string
|
|
||||||
subject: string
|
|
||||||
kind: keyof typeof WEATHER
|
|
||||||
weight: number
|
|
||||||
desc: string
|
|
||||||
d: string
|
|
||||||
labelX: number
|
|
||||||
labelY: number
|
|
||||||
emojiX: number
|
|
||||||
emojiY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const REGIONS: Region[] = [
|
|
||||||
{ id: 'maths', subject: 'Mathématiques', kind: 'tempete', weight: 4, d: 'M 150,40 Q 250,20 350,50 L 320,160 Q 220,180 160,150 Z', labelX: 230, labelY: 120, emojiX: 230, emojiY: 90, desc: 'Avis de forte tempête. Contrôle continu à fort coefficient programmé.' },
|
|
||||||
{ id: 'svt', subject: 'SVT', kind: 'vent', weight: 2, d: 'M 350,50 Q 460,80 440,200 L 310,220 Q 300,160 320,160 Z', labelX: 375, labelY: 160, emojiX: 375, emojiY: 130, desc: 'Risque de petit QCM surprise en début d’heure sur le chapitre de la géologie.' },
|
|
||||||
{ id: 'physique', subject: 'Physique-Chimie', kind: 'brouillard', weight: 2, d: 'M 160,150 Q 220,180 310,220 L 280,320 Q 180,310 140,240 Z', labelX: 220, labelY: 255, emojiX: 220, emojiY: 225, desc: 'Suspicion de TP évalué la semaine prochaine. Rumeurs soutenues par la classe.' },
|
|
||||||
{ id: 'histoire', subject: 'Histoire-Géo', kind: 'soleil', weight: 1, d: 'M 150,40 L 160,150 Q 140,240 60,220 Q 30,120 150,40 Z', labelX: 90, labelY: 160, emojiX: 90, emojiY: 130, desc: 'Conditions idéales. Aucun contrôle prévu d’ici la fin de la semaine.' },
|
|
||||||
{ id: 'francais', subject: 'Français', kind: 'soleil', weight: 1, d: 'M 140,240 Q 180,310 280,320 L 260,380 Q 120,390 60,220 Z', labelX: 170, labelY: 340, emojiX: 170, emojiY: 315, desc: 'Aucune perturbation à signaler. Cours magistral standard.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Carte SVG par matière — figée, alimentée par les données en phase 2. */
|
|
||||||
export function renderMap(root: HTMLElement, _rerender: () => void): void {
|
|
||||||
let selected = 'maths'
|
|
||||||
|
|
||||||
const detailTitle = h('h3', { class: 'text-base font-black text-slate-100' })
|
|
||||||
const detailStatus = h('p', { class: 'text-xs font-bold' })
|
|
||||||
const detailCoeff = h('span', { class: 'bg-slate-700 text-slate-200 text-xs font-black px-2.5 py-1 rounded-lg' })
|
|
||||||
const detailDesc = h('p', { class: 'text-xs text-slate-300 font-medium leading-relaxed mt-2 pt-2 border-t border-slate-700/60' })
|
|
||||||
const detailIcon = h('span', { class: 'text-3xl' })
|
|
||||||
|
|
||||||
const statusClasses: Record<string, string> = {
|
|
||||||
tempete: 'text-rose-400',
|
|
||||||
orage: 'text-rose-400',
|
|
||||||
vent: 'text-amber-400',
|
|
||||||
brouillard: 'text-purple-400',
|
|
||||||
soleil: 'text-emerald-400',
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateDetail = (region: Region): void => {
|
|
||||||
const meta = WEATHER[region.kind]
|
|
||||||
detailIcon.textContent = meta.icon
|
|
||||||
detailTitle.textContent = `Secteur : ${region.subject}`
|
|
||||||
detailStatus.textContent = `${meta.title} • ${meta.hint}`
|
|
||||||
detailStatus.className = `text-xs font-bold ${statusClasses[region.kind] ?? 'text-slate-400'}`
|
|
||||||
detailCoeff.textContent = region.weight > 0 ? `Coeff ${region.weight.toFixed(1)}` : ' Calme'
|
|
||||||
detailDesc.textContent = region.desc
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = new Map<string, SVGPathElement>()
|
|
||||||
const svg = svgEl('svg', { viewBox: '0 0 500 400' })
|
|
||||||
svg.setAttribute('class', 'w-full h-full drop-shadow-md')
|
|
||||||
|
|
||||||
for (const region of REGIONS) {
|
|
||||||
const meta = WEATHER[region.kind]
|
|
||||||
const path = svgEl('path', {
|
|
||||||
id: `region-${region.id}`,
|
|
||||||
d: region.d,
|
|
||||||
fill: meta.hex,
|
|
||||||
})
|
|
||||||
path.setAttribute('class', 'map-region')
|
|
||||||
path.setAttribute('style', 'transition:all 0.3s cubic-bezier(0.4,0,0.2,1);cursor:pointer;stroke:#0f172a;stroke-width:2.5')
|
|
||||||
|
|
||||||
path.addEventListener('click', () => {
|
|
||||||
selected = region.id
|
|
||||||
for (const p of paths.values()) p.classList.remove('active-region')
|
|
||||||
path.classList.add('active-region')
|
|
||||||
updateDetail(region)
|
|
||||||
})
|
|
||||||
|
|
||||||
paths.set(region.id, path)
|
|
||||||
svg.append(path)
|
|
||||||
|
|
||||||
const emoji = svgEl('text', {
|
|
||||||
x: region.emojiX,
|
|
||||||
y: region.emojiY,
|
|
||||||
'font-size': '22',
|
|
||||||
'pointer-events': 'none',
|
|
||||||
})
|
|
||||||
emoji.textContent = meta.icon
|
|
||||||
svg.append(emoji)
|
|
||||||
|
|
||||||
const label = svgEl('text', {
|
|
||||||
x: region.labelX,
|
|
||||||
y: region.labelY,
|
|
||||||
'font-size': '12',
|
|
||||||
'font-weight': '900',
|
|
||||||
fill: '#ffffff',
|
|
||||||
'text-anchor': 'middle',
|
|
||||||
'pointer-events': 'none',
|
|
||||||
})
|
|
||||||
label.textContent = region.subject
|
|
||||||
svg.append(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
paths.get(selected)?.classList.add('active-region')
|
|
||||||
updateDetail(REGIONS[0]!)
|
|
||||||
|
|
||||||
mount(
|
|
||||||
root,
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'space-y-4' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex justify-between items-center' },
|
|
||||||
h('div', {},
|
|
||||||
h('h2', { class: 'text-lg font-black text-slate-100' }, 'Carte Météorologique'),
|
|
||||||
h('p', { class: 'text-xs font-semibold text-slate-400' }, 'Secteurs géographiques par matière'),
|
|
||||||
),
|
|
||||||
h('span', { class: 'px-2.5 py-1 rounded-full bg-sky-500/10 text-sky-400 text-xs font-bold border border-sky-500/20' },
|
|
||||||
'Interactive'),
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'bg-slate-900 border border-slate-800 rounded-3xl p-3 shadow-lg relative overflow-hidden aspect-[4/3] flex items-center justify-center' },
|
|
||||||
svg,
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' },
|
|
||||||
h('div', { class: 'flex justify-between items-center mb-2' },
|
|
||||||
h('div', { class: 'flex items-center gap-2.5' },
|
|
||||||
detailIcon,
|
|
||||||
h('div', {}, detailTitle, detailStatus),
|
|
||||||
),
|
|
||||||
detailCoeff,
|
|
||||||
),
|
|
||||||
detailDesc,
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'p',
|
|
||||||
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
|
|
||||||
'Les secteurs seront alimentés par les données de la classe en phase 2.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,583 +0,0 @@
|
||||||
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 { 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,
|
|
||||||
} from '../store'
|
|
||||||
import { showToast } from '../toast'
|
|
||||||
import { currentAppVersion } from '../update'
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors'
|
|
||||||
|
|
||||||
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()
|
|
||||||
const admin = isAdmin()
|
|
||||||
const selfMember = getState().session?.member ?? null
|
|
||||||
|
|
||||||
const serverInput = h('input', {
|
|
||||||
class: inputClass,
|
|
||||||
value: getBaseUrl(),
|
|
||||||
autocomplete: 'off',
|
|
||||||
inputMode: 'url',
|
|
||||||
})
|
|
||||||
|
|
||||||
const nameInput = h('input', { class: inputClass, value: klass?.name ?? '', placeholder: 'Terminale S2' })
|
|
||||||
const levelInput = h('input', { class: inputClass, value: klass?.level ?? '', placeholder: 'Lycée' })
|
|
||||||
const flashInput = h('textarea', {
|
|
||||||
class: `${inputClass} min-h-[70px] resize-none`,
|
|
||||||
placeholder: 'Message du jour pour la classe…',
|
|
||||||
})
|
|
||||||
flashInput.value = flash?.message ?? ''
|
|
||||||
|
|
||||||
const legendItems: { icon: string; title: string; accent: string; desc: string }[] = [
|
|
||||||
{ icon: '☀️', title: 'Grand Soleil', accent: 'text-sky-400', desc: 'Journée calme. Aucun examen répertorié ni suspecté.' },
|
|
||||||
{ icon: '🌫️', title: 'Brouillard', accent: 'text-purple-400', desc: 'Zone d’incertitude. Rumeur ou sous-entendu d’un professeur.' },
|
|
||||||
{ icon: '💨', title: 'Coup de vent', accent: 'text-amber-400', desc: 'Interrogation surprise potentielle ou petit QCM (coeff faible).' },
|
|
||||||
{ icon: '🌩️', title: 'Orage / Pluie', accent: 'text-rose-400', desc: 'Devoir Surveillé (DS) confirmé ou évaluation de chapitre.' },
|
|
||||||
{ icon: '🚨', title: 'Alerte Rouge / Tempête', accent: 'text-red-500', desc: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const children: (Node | null)[] = []
|
|
||||||
|
|
||||||
// === PANNEAU ADMIN COMPLET ================================================
|
|
||||||
if (admin) {
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
|
|
||||||
// --- 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 ---
|
|
||||||
children.push(
|
|
||||||
h(
|
|
||||||
'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-3' }, 'Serveur API'),
|
|
||||||
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: `${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'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Session ---
|
|
||||||
children.push(
|
|
||||||
h(
|
|
||||||
'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'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Légende ---
|
|
||||||
children.push(
|
|
||||||
h('div', {},
|
|
||||||
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('span', { class: 'text-3xl' }, item.icon),
|
|
||||||
h('div', {},
|
|
||||||
h('h4', { class: `text-sm font-black ${item.accent}` }, item.title),
|
|
||||||
h('p', { class: 'text-xs text-slate-300 font-medium' }, item.desc),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
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`),
|
|
||||||
)
|
|
||||||
|
|
||||||
mount(root, ...children.filter((c): c is Node => c !== null))
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 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,
|
|
||||||
rerender: () => void,
|
|
||||||
): Promise<void> {
|
|
||||||
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, rerender))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
container.replaceChildren(
|
|
||||||
h('p', { class: 'text-[11px] text-rose-400 font-semibold' },
|
|
||||||
error instanceof Error ? error.message : 'Erreur'),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberRow(
|
|
||||||
member: Member,
|
|
||||||
selfMember: { id: number; role: Role } | null,
|
|
||||||
rerender: () => void,
|
|
||||||
): HTMLElement {
|
|
||||||
const isSelf = selfMember?.id === member.id
|
|
||||||
const memberIsAdmin = member.role === 'admin'
|
|
||||||
|
|
||||||
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')
|
|
||||||
: 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' })
|
|
||||||
|
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
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('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),
|
|
||||||
badge,
|
|
||||||
isSelf ? h('span', { class: 'text-[9px] text-slate-500 font-semibold' }, '(vous)') : null,
|
|
||||||
),
|
|
||||||
actions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeRole(memberId: number, role: Role, rerender: () => void): Promise<void> {
|
|
||||||
try {
|
|
||||||
await api.setMemberRole(memberId, role)
|
|
||||||
showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success')
|
|
||||||
rerender()
|
|
||||||
} catch (error) {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +1,28 @@
|
||||||
import { h, mount } from '../dom'
|
import { h, mount } from '../dom'
|
||||||
|
|
||||||
/** Radar de rumeurs — figé, branché sur le backend en phase 2. */
|
|
||||||
export function renderRadar(root: HTMLElement, _rerender: () => void): void {
|
export function renderRadar(root: HTMLElement, _rerender: () => void): void {
|
||||||
mount(
|
mount(
|
||||||
root,
|
root,
|
||||||
h(
|
h('div', { class: 'tab-enter' },
|
||||||
'div',
|
h('div', { class: 'section-title', style: 'margin-top:0' },
|
||||||
{ class: 'space-y-4' },
|
h('h3', {}, 'Radar des rumeurs'),
|
||||||
h(
|
h('span', {}, 'communautaire'),
|
||||||
'div',
|
|
||||||
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-3.5 shadow-md' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex gap-3' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'w-9 h-9 rounded-full bg-sky-500/20 text-sky-400 flex items-center justify-center font-bold text-sm shrink-0' },
|
|
||||||
h('i', { class: 'fa-solid fa-bullhorn' }),
|
|
||||||
),
|
),
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('div', { style: 'display:flex;gap:9px' },
|
||||||
h('input', {
|
h('input', {
|
||||||
type: 'text',
|
class: 'app-input', style: 'flex:1',
|
||||||
placeholder: 'Signaler une suspicion d’examen… (bientôt disponible)',
|
placeholder: 'Signaler une suspicion d’évaluation…',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
class: 'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2 text-sm text-slate-400 placeholder-slate-500 focus:outline-none',
|
|
||||||
}),
|
}),
|
||||||
),
|
h('button', { class: 'btn-primary', style: 'padding:0 14px', disabled: true }, 'Publier'),
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex justify-between items-center mt-3 pt-2 border-t border-slate-700/50' },
|
|
||||||
h('span', { class: 'text-xs text-slate-500 font-semibold' },
|
|
||||||
h('i', { class: 'fa-solid fa-circle-info text-slate-500 mr-1' }),
|
|
||||||
'Module à venir en phase 2'),
|
|
||||||
h('button', {
|
|
||||||
class: 'bg-slate-700 text-slate-400 px-4 py-1.5 rounded-xl font-extrabold text-xs',
|
|
||||||
disabled: true,
|
|
||||||
}, 'Publier'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
h(
|
h('div', { class: 'surface-card', style: 'text-align:center;padding:30px 20px' },
|
||||||
'div',
|
h('div', { style: 'font-size:48px;margin-bottom:12px' }, '📡'),
|
||||||
{ class: 'bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-3 flex items-center justify-between text-xs text-indigo-300 font-bold' },
|
h('h4', { class: 'font-bold', style: 'font-size:15px;font-family:Fredoka,sans-serif;margin:0 0 6px' }, 'Radar de rumeurs — bientôt'),
|
||||||
h('span', {},
|
h('p', { style: 'font-size:12px;color:var(--muted);font-weight:700;line-height:1.5;margin:0' },
|
||||||
h('i', { class: 'fa-solid fa-hand-pointer mr-1.5' }),
|
'Le module collaboratif (soumettre une rumeur, voter, conversion automatique au seuil de 65 %) sera branché sur le serveur dans une prochaine version.'),
|
||||||
'Glissez pour confirmer ou rejeter les rumeurs — bientôt !'),
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'text-center py-16 px-6' },
|
|
||||||
h('div', { class: 'text-5xl mb-4' }, '📡'),
|
|
||||||
h('p', { class: 'text-sm font-black text-slate-200' }, 'Radar de rumeurs — phase 2'),
|
|
||||||
h('p', { class: 'text-xs text-slate-400 font-semibold mt-1.5 leading-relaxed' },
|
|
||||||
'Le module collaboratif (soumettre une rumeur, voter, conversion automatique au seuil de 65 %) sera branché sur le serveur dans la prochaine itération.'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
458
web/src/views/settings.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
import { h, mount } from '../dom'
|
||||||
|
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
|
||||||
|
import { api } from '../api'
|
||||||
|
import {
|
||||||
|
currentFlash,
|
||||||
|
currentKlass,
|
||||||
|
dayStatus,
|
||||||
|
examsByDay,
|
||||||
|
getState,
|
||||||
|
isAdmin,
|
||||||
|
isDayOverridden,
|
||||||
|
removeExam,
|
||||||
|
saveExam,
|
||||||
|
setDayWeather,
|
||||||
|
setFlash,
|
||||||
|
signOut,
|
||||||
|
updateClass,
|
||||||
|
} from '../store'
|
||||||
|
import { showToast } from '../toast'
|
||||||
|
import { currentAppVersion } from '../update'
|
||||||
|
import { dayKey, formatDuration, formatTime, shortDate } from '../dates'
|
||||||
|
import { WEATHER, WEATHER_KINDS, kindFromWeight } from '@meteo-exam/shared'
|
||||||
|
import type { Exam, Member, Role, WeatherKind } from '@meteo-exam/shared'
|
||||||
|
import { weatherArt } from './weather-art'
|
||||||
|
|
||||||
|
export function renderSettings(root: HTMLElement, rerender: () => void): void {
|
||||||
|
const klass = currentKlass()
|
||||||
|
const flash = currentFlash()
|
||||||
|
const admin = isAdmin()
|
||||||
|
const selfMember = getState().session?.member ?? null
|
||||||
|
const theme = document.documentElement.dataset.theme ?? 'dark'
|
||||||
|
|
||||||
|
const children: (Node | null)[] = []
|
||||||
|
|
||||||
|
// Titre
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'section-title', style: 'margin-top:0' },
|
||||||
|
h('h3', {}, 'Paramètres'),
|
||||||
|
h('span', {}, 'préférences'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// === PANNEAU ADMIN =========================================================
|
||||||
|
if (admin) {
|
||||||
|
const flashInput = h('textarea', { class: 'app-input', placeholder: 'Message du jour…', style: 'min-height:60px;resize:none' })
|
||||||
|
flashInput.value = flash?.message ?? ''
|
||||||
|
|
||||||
|
const nameInput = h('input', { class: 'app-input', value: klass?.name ?? '' })
|
||||||
|
const levelInput = h('input', { class: 'app-input', value: klass?.level ?? '' })
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('div', { style: 'display:flex;align-items:center;gap:8px;margin-bottom:12px' },
|
||||||
|
h('span', { style: 'color:var(--blue2)' }, '🛡'),
|
||||||
|
h('strong', { style: 'font-size:13px;font-family:Fredoka,sans-serif' }, 'Configuration admin'),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Flash info
|
||||||
|
h('div', { style: 'margin-bottom:14px' },
|
||||||
|
h('span', { class: 'app-label' }, 'Flash info du jour'),
|
||||||
|
flashInput,
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-primary', style: 'margin-top:8px;width:100%',
|
||||||
|
onclick: async () => {
|
||||||
|
try { await setFlash(flashInput.value.trim()); showToast('Flash info mis à jour', 'success'); rerender() }
|
||||||
|
catch (e) { showToast(e instanceof Error ? e.message : 'Erreur', 'reject') }
|
||||||
|
},
|
||||||
|
}, 'Publier le flash info'),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Édition classe
|
||||||
|
h('div', { style: 'margin-bottom:14px;padding-top:12px;border-top:1px solid var(--border)' },
|
||||||
|
h('span', { class: 'app-label' }, 'Nom de la classe'),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:8px' },
|
||||||
|
nameInput, levelInput),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-secondary', style: 'width:100%',
|
||||||
|
onclick: async () => {
|
||||||
|
try { await updateClass(nameInput.value.trim(), levelInput.value.trim() || null); showToast('Classe mise à jour', 'success'); rerender() }
|
||||||
|
catch (e) { showToast(e instanceof Error ? e.message : 'Erreur', 'reject') }
|
||||||
|
},
|
||||||
|
}, 'Enregistrer la classe'),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Évaluations
|
||||||
|
examsAdminSection(rerender),
|
||||||
|
|
||||||
|
// Météo par jour
|
||||||
|
weatherAdminSection(rerender),
|
||||||
|
|
||||||
|
// Membres
|
||||||
|
membersAdminSection(selfMember, rerender),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === APPARENCE ==============================================================
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('strong', { style: 'font-size:13px;font-family:Fredoka,sans-serif' }, 'Apparence'),
|
||||||
|
h('div', { class: 'theme-btns' },
|
||||||
|
h('button', {
|
||||||
|
class: theme === 'light' ? 'active' : '',
|
||||||
|
onclick: () => { document.documentElement.dataset.theme = 'light'; rerender(); showToast('Mode clair activé', 'info') },
|
||||||
|
}, '☀ Mode clair'),
|
||||||
|
h('button', {
|
||||||
|
class: theme === 'dark' ? 'active' : '',
|
||||||
|
onclick: () => { document.documentElement.dataset.theme = 'dark'; rerender(); showToast('Mode sombre activé', 'info') },
|
||||||
|
}, '☾ Mode sombre'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// === SERVEUR ================================================================
|
||||||
|
const serverInput = h('input', { class: 'app-input', value: getBaseUrl(), inputMode: 'url' })
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('strong', { style: 'font-size:13px;font-family:Fredoka,sans-serif' }, 'Serveur API'),
|
||||||
|
h('div', { style: 'margin:8px 0' }, serverInput),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-primary', style: 'width:100%',
|
||||||
|
onclick: () => { setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL); showToast('Adresse enregistrée', 'success'); rerender() },
|
||||||
|
}, 'Enregistrer'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// === SESSION ================================================================
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('strong', { style: 'font-size:13px;font-family:Fredoka,sans-serif' }, 'Session'),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-secondary', style: 'width:100%;margin-top:8px;border-color:var(--red);color:var(--red)',
|
||||||
|
onclick: () => { signOut(); rerender() },
|
||||||
|
}, 'Se déconnecter'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// === IDENTITÉ VISUELLE =====================================================
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card' },
|
||||||
|
h('strong', { style: 'font-size:13px;font-family:Fredoka,sans-serif' }, 'Identité visuelle'),
|
||||||
|
h('p', { style: 'font-size:11px;color:var(--muted);font-weight:700;line-height:1.45;margin:8px 0 0' },
|
||||||
|
'Logo inspiré d’une copie scolaire : papier quadrillé, zéro manuscrit rouge. Les illustrations météo utilisent des formes géométriques simples et des dégradés doux.'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
children.push(
|
||||||
|
h('p', { style: 'font-size:10px;color:var(--muted);font-weight:800;text-align:center;margin-top:12px' },
|
||||||
|
`Météo Exam — v${currentAppVersion()} · Le ciel de tes évaluations`),
|
||||||
|
)
|
||||||
|
|
||||||
|
mount(root, ...children.filter((c): c is Node => c !== null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === SECTIONS ADMIN ==========================================================
|
||||||
|
|
||||||
|
function examsAdminSection(rerender: () => void): HTMLElement {
|
||||||
|
const { stripDays } = getState()
|
||||||
|
const grouped = examsByDay()
|
||||||
|
const rows = stripDays.flatMap((date) => {
|
||||||
|
const key = dayKey(date)
|
||||||
|
return (grouped.get(key) ?? []).map((exam) => examRow(exam, rerender))
|
||||||
|
})
|
||||||
|
|
||||||
|
return h('div', { style: 'padding-top:12px;border-top:1px solid var(--border);margin-bottom:14px' },
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px' },
|
||||||
|
h('span', { class: 'app-label', style: 'margin:0' }, 'Évaluations (7 jours)'),
|
||||||
|
h('span', { style: 'font-size:10px;color:var(--muted);font-weight:800' }, `${rows.length} total`),
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:grid;gap:6px;max-height:250px;overflow-y:auto' },
|
||||||
|
rows.length === 0
|
||||||
|
? h('p', { style: 'font-size:11px;color:var(--muted);font-weight:700' }, 'Aucune évaluation.')
|
||||||
|
: null,
|
||||||
|
...rows,
|
||||||
|
),
|
||||||
|
h('button', { class: 'btn-primary', style: 'margin-top:8px;width:100%', onclick: () => openExamForm(undefined, rerender) },
|
||||||
|
'+ Ajouter une évaluation'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function examRow(exam: Exam, rerender: () => void): HTMLElement {
|
||||||
|
const meta = WEATHER[exam.kind]
|
||||||
|
return h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:8px 10px' },
|
||||||
|
h('div', { style: 'display:flex;align-items:center;gap:8px;min-width:0' },
|
||||||
|
h('span', { class: 'text-base' }, meta.icon),
|
||||||
|
h('div', { style: 'min-width:0' },
|
||||||
|
h('p', { class: 'font-bold', style: 'font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, exam.subject),
|
||||||
|
h('p', { style: 'font-size:10px;color:var(--muted);font-weight:700' },
|
||||||
|
`${shortDate(new Date(exam.startsAt))} · ${formatTime(exam.startsAt)} · ${formatDuration(exam.durationMin)}`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:flex;gap:4px;flex-shrink:0' },
|
||||||
|
h('button', {
|
||||||
|
style: 'padding:4px 8px;border-radius:8px;background:color-mix(in srgb,var(--blue) 12%,transparent);color:var(--blue2);font-size:10px;font-weight:900;border:0;cursor:pointer',
|
||||||
|
onclick: () => openExamForm(exam, rerender),
|
||||||
|
}, '✏'),
|
||||||
|
h('button', {
|
||||||
|
style: 'padding:4px 8px;border-radius:8px;background:color-mix(in srgb,var(--red) 12%,transparent);color:var(--red);font-size:10px;font-weight:900;border:0;cursor:pointer',
|
||||||
|
onclick: async () => {
|
||||||
|
try { await removeExam(exam.id); showToast('Supprimée', 'success'); rerender() }
|
||||||
|
catch (e) { showToast(e instanceof Error ? e.message : 'Erreur', 'reject') }
|
||||||
|
},
|
||||||
|
}, '🗑'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function weatherAdminSection(rerender: () => void): HTMLElement {
|
||||||
|
const { stripDays } = getState()
|
||||||
|
|
||||||
|
return h('div', { style: 'padding-top:12px;border-top:1px solid var(--border);margin-bottom:14px' },
|
||||||
|
h('span', { class: 'app-label' }, 'Météo par jour'),
|
||||||
|
h('p', { style: 'font-size:10px;color:var(--muted);font-weight:700;margin:0 0 8px' }, 'Force un statut météo pour un jour.'),
|
||||||
|
h('div', { style: 'display:grid;gap:6px' },
|
||||||
|
...stripDays.map((date) => {
|
||||||
|
const key = dayKey(date)
|
||||||
|
const status = dayStatus(key)
|
||||||
|
const meta = WEATHER[status]
|
||||||
|
const overridden = isDayOverridden(key)
|
||||||
|
return h('button', {
|
||||||
|
class: 'day-row',
|
||||||
|
style: overridden ? 'border-color:var(--blue)' : '',
|
||||||
|
onclick: () => openDayWeatherModal(key, rerender),
|
||||||
|
},
|
||||||
|
weatherArt(status, 'mini'),
|
||||||
|
h('div', {},
|
||||||
|
h('strong', { class: 'block text-xs font-bold', style: 'font-family:Fredoka,sans-serif' }, shortDate(date)),
|
||||||
|
h('small', { style: `font-size:10px;color:${meta.hex};font-weight:800` }, meta.title),
|
||||||
|
),
|
||||||
|
h('span', { style: `font-size:9px;font-weight:900;text-transform:uppercase;color:${overridden ? 'var(--blue2)' : 'var(--muted)'}` },
|
||||||
|
overridden ? 'forcé' : 'auto'),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDayWeatherModal(dayKeyValue: string, rerender: () => void): void {
|
||||||
|
const overlay = h('div', { class: 'modal-overlay' })
|
||||||
|
const close = (): void => overlay.remove()
|
||||||
|
|
||||||
|
const panel = h('div', { class: 'modal-panel' },
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;margin-bottom:12px' },
|
||||||
|
h('h2', { class: 'font-bold', style: 'font-size:18px;font-family:Fredoka,sans-serif;margin:0' }, 'Météo du jour'),
|
||||||
|
h('button', { style: 'background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer', onclick: close }, '✕'),
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:grid;gap:8px' },
|
||||||
|
...WEATHER_KINDS.map((kind) => {
|
||||||
|
const meta = WEATHER[kind]
|
||||||
|
return h('button', {
|
||||||
|
class: 'day-row',
|
||||||
|
onclick: async () => {
|
||||||
|
try { await setDayWeather(dayKeyValue, kind); showToast(`Météo forcée : ${meta.label}`, 'success'); close(); rerender() }
|
||||||
|
catch (e) { showToast(e instanceof Error ? e.message : 'Erreur', 'reject') }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
weatherArt(kind, 'mini'),
|
||||||
|
h('div', {},
|
||||||
|
h('strong', { class: 'block text-sm font-bold', style: 'font-family:Fredoka,sans-serif' }, meta.title),
|
||||||
|
h('small', { style: 'font-size:10px;color:var(--muted);font-weight:700' }, meta.hint),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
overlay.append(panel)
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||||
|
document.body.append(overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function membersAdminSection(selfMember: { id: number; role: Role } | null, rerender: () => void): HTMLElement {
|
||||||
|
const container = h('div', { style: 'display:grid;gap:6px' },
|
||||||
|
h('p', { style: 'font-size:11px;color:var(--muted);font-weight:700' }, 'Chargement…'))
|
||||||
|
void loadMembers(container, selfMember, rerender)
|
||||||
|
|
||||||
|
return h('div', { style: 'padding-top:12px;border-top:1px solid var(--border)' },
|
||||||
|
h('span', { class: 'app-label' }, 'Membres de la classe'),
|
||||||
|
container,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers(container: HTMLElement, selfMember: { id: number; role: Role } | null, rerender: () => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
const members = await api.listMembers()
|
||||||
|
container.replaceChildren()
|
||||||
|
if (members.length === 0) {
|
||||||
|
container.append(h('p', { style: 'font-size:11px;color:var(--muted);font-weight:700' }, 'Aucun membre.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const member of members) {
|
||||||
|
container.append(memberRow(member, selfMember, rerender))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
container.replaceChildren(
|
||||||
|
h('p', { style: 'font-size:11px;color:var(--red);font-weight:700' },
|
||||||
|
error instanceof Error ? error.message : 'Erreur'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberRow(member: Member, selfMember: { id: number; role: Role } | null, rerender: () => void): HTMLElement {
|
||||||
|
const isSelf = selfMember?.id === member.id
|
||||||
|
const memberIsAdmin = member.role === 'admin'
|
||||||
|
|
||||||
|
const badgeColor = member.isCreator ? 'var(--orange)' : memberIsAdmin ? 'var(--blue2)' : 'var(--muted)'
|
||||||
|
const badgeText = member.isCreator ? 'Créateur' : memberIsAdmin ? 'Admin' : 'Élève'
|
||||||
|
|
||||||
|
return h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:8px 10px' },
|
||||||
|
h('div', { style: 'display:flex;align-items:center;gap:8px;min-width:0' },
|
||||||
|
h('span', { style: 'width:28px;height:28px;border-radius:50%;background:var(--border);display:grid;place-items:center;font-size:11px;font-weight:900;flex-shrink:0' },
|
||||||
|
member.nickname.charAt(0).toUpperCase()),
|
||||||
|
h('span', { class: 'font-bold', style: 'font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, member.nickname),
|
||||||
|
h('span', { style: `font-size:9px;font-weight:900;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,${badgeColor} 12%,transparent);color:${badgeColor};border:1px solid color-mix(in srgb,${badgeColor} 25%,transparent)` }, badgeText),
|
||||||
|
isSelf ? h('span', { style: 'font-size:9px;color:var(--muted);font-weight:700' }, '(vous)') : null,
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:flex;gap:4px;flex-shrink:0' },
|
||||||
|
!memberIsAdmin
|
||||||
|
? h('button', {
|
||||||
|
style: 'padding:4px 8px;border-radius:8px;background:color-mix(in srgb,var(--blue) 12%,transparent);color:var(--blue2);font-size:10px;font-weight:900;border:0;cursor:pointer',
|
||||||
|
onclick: () => changeRole(member.id, 'admin', rerender),
|
||||||
|
}, '↑')
|
||||||
|
: null,
|
||||||
|
memberIsAdmin && !member.isCreator && !isSelf
|
||||||
|
? h('button', {
|
||||||
|
style: 'padding:4px 8px;border-radius:8px;background:color-mix(in srgb,var(--red) 12%,transparent);color:var(--red);font-size:10px;font-weight:900;border:0;cursor:pointer',
|
||||||
|
onclick: () => changeRole(member.id, 'student', rerender),
|
||||||
|
}, '↓')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRole(memberId: number, role: Role, rerender: () => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.setMemberRole(memberId, role)
|
||||||
|
showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success')
|
||||||
|
rerender()
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Erreur', 'reject')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === FORMULAIRE ÉVALUATION ===================================================
|
||||||
|
|
||||||
|
function openExamForm(exam: Exam | undefined, rerender: () => void): void {
|
||||||
|
const overlay = h('div', { class: 'modal-overlay' })
|
||||||
|
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: 'app-input', value: exam?.subject ?? '', placeholder: 'Mathématiques' })
|
||||||
|
const teacher = h('input', { class: 'app-input', value: exam?.teacher ?? '' })
|
||||||
|
const room = h('input', { class: 'app-input', value: exam?.room ?? '' })
|
||||||
|
const chapter = h('input', { class: 'app-input', value: exam?.chapter ?? '' })
|
||||||
|
const day = h('input', { type: 'date', class: 'app-input', value: exam ? isoDay(new Date(exam.startsAt)) : isoDay(now) })
|
||||||
|
const time = h('input', { type: 'time', class: 'app-input', 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: 'app-input', value: String(exam?.durationMin ?? 60), min: '5', step: '5' })
|
||||||
|
const weight = h('input', { type: 'number', class: 'app-input', value: String(exam?.weight ?? 2), min: '0', max: '20', step: '0.5' })
|
||||||
|
const note = h('textarea', { class: 'app-input', style: 'min-height:50px;resize:none' })
|
||||||
|
note.value = exam?.note ?? ''
|
||||||
|
|
||||||
|
let kind: WeatherKind = exam?.kind ?? 'pluie'
|
||||||
|
let kindOverride = false
|
||||||
|
const kindPreview = h('div', { style: 'display:flex;flex-wrap:wrap;gap:6px' })
|
||||||
|
|
||||||
|
const renderKinds = (): void => {
|
||||||
|
kindPreview.replaceChildren(
|
||||||
|
...WEATHER_KINDS.map((k) => {
|
||||||
|
const meta = WEATHER[k]
|
||||||
|
const active = kind === k
|
||||||
|
return h('button', {
|
||||||
|
type: 'button',
|
||||||
|
style: `padding:5px 8px;border-radius:8px;font-size:10px;font-weight:900;border:1px solid ${active ? meta.hex : 'var(--border)'};background:${active ? `color-mix(in srgb,${meta.hex} 15%,var(--surface2))` : 'var(--surface2)'};color:${active ? meta.hex : 'var(--muted)'};cursor:pointer`,
|
||||||
|
onclick: () => { kind = k; kindOverride = true; renderKinds() },
|
||||||
|
}, `${meta.icon} ${meta.label}`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
weight.addEventListener('input', () => {
|
||||||
|
const v = Number(weight.value)
|
||||||
|
if (Number.isFinite(v) && !kindOverride) { kind = kindFromWeight(v); renderKinds() }
|
||||||
|
})
|
||||||
|
renderKinds()
|
||||||
|
|
||||||
|
const errorBox = h('p', { style: 'font-size:12px;font-weight:700;color:#FB7185;display:none' })
|
||||||
|
const showError = (msg: string): void => { errorBox.textContent = msg; errorBox.style.display = 'block' }
|
||||||
|
|
||||||
|
const submitBtn = h('button', {
|
||||||
|
class: 'btn-primary', style: 'flex:1',
|
||||||
|
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.style.opacity = '0.6'
|
||||||
|
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' : 'Ajoutée', 'success')
|
||||||
|
close(); rerender()
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : 'Erreur')
|
||||||
|
submitBtn.disabled = false
|
||||||
|
submitBtn.style.opacity = '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, editing ? 'Enregistrer' : 'Ajouter')
|
||||||
|
|
||||||
|
const panel = h('div', { class: 'modal-panel' },
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;margin-bottom:12px' },
|
||||||
|
h('h2', { class: 'font-bold', style: 'font-size:18px;font-family:Fredoka,sans-serif;margin:0' },
|
||||||
|
editing ? 'Modifier' : 'Nouvelle évaluation'),
|
||||||
|
h('button', { style: 'background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer', onclick: close }, '✕'),
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:grid;gap:10px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Matière'), subject),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:8px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Prof'), teacher),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Salle'), room)),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Chapitre'), chapter),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:8px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Date'), day),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Heure'), time)),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:8px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Durée (min)'), duration),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Coeff'), weight)),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Statut météo'), kindPreview),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Note'), note),
|
||||||
|
errorBox,
|
||||||
|
h('div', { style: 'display:flex;gap:8px' },
|
||||||
|
h('button', { class: 'btn-secondary', onclick: close }, 'Annuler'),
|
||||||
|
submitBtn),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
overlay.append(panel)
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||||
|
document.body.append(overlay)
|
||||||
|
subject.focus()
|
||||||
|
}
|
||||||
64
web/src/views/weather-art.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { WeatherKind } from '@meteo-exam/shared'
|
||||||
|
import { h } from '../dom'
|
||||||
|
|
||||||
|
/** Illustration CSS d'un état météo (style vectoriel V2). */
|
||||||
|
export function weatherArt(kind: WeatherKind, size: 'mini' | 'hero' = 'mini'): HTMLElement {
|
||||||
|
const cls = size === 'mini' ? 'mini-art' : 'relative w-full'
|
||||||
|
const style = size === 'hero' ? 'height:120px;border-radius:16px;overflow:hidden;position:relative' : ''
|
||||||
|
|
||||||
|
const art: Record<WeatherKind, () => HTMLElement> = {
|
||||||
|
arcenciel: () => h('div', { class: cls, style: `${style};background:linear-gradient(145deg,#1570EF,#7555D9)` },
|
||||||
|
h('div', { class: 'rainbow-shape', style: 'transform:translate(-50%,-50%) scale(.42)' },
|
||||||
|
h('div', { class: 'rainbow-arc' }),
|
||||||
|
cloudPuff(3, 2, 65, 25),
|
||||||
|
cloudPuff(1, 0, 66, 26),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
soleil: () => h('div', { class: cls, style: `${style};background:linear-gradient(145deg,#1488FF,#55B8FF)` },
|
||||||
|
h('div', { class: 'sun-shape' }),
|
||||||
|
),
|
||||||
|
brouillard: () => h('div', { class: cls, style: `${style};background:linear-gradient(145deg,#A6B5C9,#7288A8)` },
|
||||||
|
h('div', { class: 'simple-cloud' }),
|
||||||
|
h('div', { class: 'fog-lines' }),
|
||||||
|
),
|
||||||
|
nuageux: () => h('div', { class: cls, style: `${style};background:linear-gradient(145deg,#57B7FF,#2D86ED)` },
|
||||||
|
h('div', { class: 'simple-cloud' }),
|
||||||
|
),
|
||||||
|
pluie: () => h('div', { class: cls, style: `${style};background:linear-gradient(145deg,#14589B,#092E64)` },
|
||||||
|
h('div', { class: 'simple-cloud' }),
|
||||||
|
h('div', { class: 'rain-drops' }),
|
||||||
|
),
|
||||||
|
orage: () => h('div', { class: cls, style: `${style};background:linear-gradient(145deg,#291B69,#152758)` },
|
||||||
|
h('div', { class: 'simple-cloud', style: 'background:#5464A9' }),
|
||||||
|
h('div', { class: 'bolt-shape' }),
|
||||||
|
),
|
||||||
|
tempete: () => h('div', { class: cls, style: `${style};background:radial-gradient(circle at 50% 50%,#FBAE45 0 5%,#F04464 6% 12%,#7E2BD5 13% 21%,#11194B 22% 36%,#F04464 37% 43%,#11194B 44%)` }),
|
||||||
|
fin_du_monde: () => h('div', { class: cls, style: `${style};background:linear-gradient(#281846,#B33C33 55%,#F59337)` },
|
||||||
|
h('div', { class: 'mushroom-shape' }),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return art[kind]()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloudPuff(_n: number, _pos: number, w: number, ht: number): HTMLElement {
|
||||||
|
return h('div', {
|
||||||
|
class: 'cloud-puff',
|
||||||
|
style: `width:${w}px;height:${ht}px;left:${_pos}px;bottom:2px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gradient de fond pour les cartes hero. */
|
||||||
|
export function heroGradient(kind: WeatherKind): string {
|
||||||
|
const gradients: Record<WeatherKind, string> = {
|
||||||
|
arcenciel: 'linear-gradient(145deg,#1570EF 0%,#465CDB 55%,#8B5CF6 100%)',
|
||||||
|
soleil: 'linear-gradient(145deg,#1488FF 0%,#55B8FF 50%,#88D4F8 100%)',
|
||||||
|
brouillard: 'linear-gradient(145deg,#6B7C93 0%,#4A5A72 50%,#3D4F68 100%)',
|
||||||
|
nuageux: 'linear-gradient(145deg,#2D86ED 0%,#57B7FF 50%,#89D0FF 100%)',
|
||||||
|
pluie: 'linear-gradient(145deg,#092E64 0%,#14589B 50%,#1E6FD4 100%)',
|
||||||
|
orage: 'linear-gradient(145deg,#152758 0%,#291B69 50%,#3D2B8A 100%)',
|
||||||
|
tempete: 'radial-gradient(circle at 50% 50%,#FBAE45 0 5%,#F04464 6% 12%,#7E2BD5 13% 21%,#11194B 22% 36%,#F04464 37% 43%,#11194B 44%)',
|
||||||
|
fin_du_monde: 'linear-gradient(#281846,#B33C33 55%,#F59337)',
|
||||||
|
}
|
||||||
|
return gradients[kind]
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import type { Exam } from '@meteo-exam/shared'
|
import type { Exam, WeatherKind } from '@meteo-exam/shared'
|
||||||
import { WEATHER } from '@meteo-exam/shared'
|
import { WEATHER, WEATHER_KINDS } from '@meteo-exam/shared'
|
||||||
import { h, mount } from '../dom'
|
import { h, mount } from '../dom'
|
||||||
import {
|
import { dayKey, dayName, formatDuration, formatTime, isSameDay, longDate } from '../dates'
|
||||||
dayKey,
|
import { weatherArt, heroGradient } from './weather-art'
|
||||||
dayNameShort,
|
|
||||||
dayNumber,
|
|
||||||
formatDuration,
|
|
||||||
formatTime,
|
|
||||||
isSameDay,
|
|
||||||
longDate,
|
|
||||||
shortDate,
|
|
||||||
} from '../dates'
|
|
||||||
import { examCard, examDetail, weatherBadge } from './cards'
|
|
||||||
import {
|
import {
|
||||||
clearDayWeather,
|
clearDayWeather,
|
||||||
currentFlash,
|
currentFlash,
|
||||||
|
|
@ -21,410 +12,404 @@ import {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isDayOverridden,
|
isDayOverridden,
|
||||||
removeExam,
|
removeExam,
|
||||||
|
saveExam,
|
||||||
selectDay,
|
selectDay,
|
||||||
setDayWeather,
|
setDayWeather,
|
||||||
setError,
|
setError,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import { showToast } from '../toast'
|
import { showToast } from '../toast'
|
||||||
import { openExamForm } from './exam-form'
|
|
||||||
|
|
||||||
/** Carte géante « Aujourd'hui » : l'évaluation la plus menaçante du jour. */
|
export function renderWeek(root: HTMLElement, rerender: () => void): void {
|
||||||
|
const { stripDays, selectedDay, error } = getState()
|
||||||
|
const today = new Date()
|
||||||
|
const open = (exam: Exam): void => openDetail(exam, rerender)
|
||||||
|
|
||||||
|
const children: (Node | null)[] = []
|
||||||
|
|
||||||
|
// Flash info
|
||||||
|
const flash = currentFlash()
|
||||||
|
if (flash?.message) {
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card', style: 'display:flex;align-items:flex-start;gap:12px;margin-bottom:14px' },
|
||||||
|
h('span', { style: 'font-size:20px;line-height:1' }, '📻'),
|
||||||
|
h('div', { style: 'flex:1' },
|
||||||
|
h('p', { style: 'font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:.06em;color:var(--blue2)' }, 'Flash info'),
|
||||||
|
h('p', { style: 'font-size:14px;font-weight:800;color:var(--text)' }, flash.message),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero card
|
||||||
|
children.push(todayHero(open))
|
||||||
|
|
||||||
|
// Section title
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'section-title' },
|
||||||
|
h('h3', {}, 'Prévisions de la semaine'),
|
||||||
|
h('span', {}, '7 prochains jours'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Forecast list
|
||||||
|
children.push(
|
||||||
|
h('div', { style: 'display:grid;gap:10px' },
|
||||||
|
...stripDays.map((date) => {
|
||||||
|
const key = dayKey(date)
|
||||||
|
const status = dayStatus(key)
|
||||||
|
const meta = WEATHER[status]
|
||||||
|
const count = examsByDay().get(key)?.length ?? 0
|
||||||
|
const isToday = isSameDay(date, today)
|
||||||
|
const overridden = isDayOverridden(key)
|
||||||
|
const active = selectedDay === key
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
class: 'day-row',
|
||||||
|
style: active ? 'border-color:var(--blue);box-shadow:0 0 0 2px color-mix(in srgb,var(--blue) 20%,transparent)' : '',
|
||||||
|
onclick: () => {
|
||||||
|
if (isAdmin()) openDayWeatherModal(key, rerender)
|
||||||
|
else selectDay(key)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
weatherArt(status, 'mini'),
|
||||||
|
h('div', { style: 'min-width:0' },
|
||||||
|
h('strong', { style: 'display:block;font-family:Fredoka,sans-serif;font-size:14px' },
|
||||||
|
`${isToday ? 'Aujourd’hui' : dayName(date)} — ${meta.label}`),
|
||||||
|
h('small', { style: 'display:block;color:var(--muted);font-weight:700;font-size:10px;line-height:1.25;margin-top:2px' },
|
||||||
|
count === 0 ? 'Aucune évaluation prévue.' : `${count} évaluation(s) — ${meta.hint}`),
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px' },
|
||||||
|
h('span', { class: 'status-dot', style: `background:${meta.hex}` }),
|
||||||
|
overridden
|
||||||
|
? h('span', { style: 'font-size:8px;font-weight:900;text-transform:uppercase;color:var(--blue2)' }, 'forcé')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Legend 8 niveaux
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'section-title' },
|
||||||
|
h('h3', {}, 'Les 8 niveaux'),
|
||||||
|
h('span', {}, 'référence graphique'),
|
||||||
|
),
|
||||||
|
h('div', { class: 'legend-grid' },
|
||||||
|
...WEATHER_KINDS.map((kind) => {
|
||||||
|
const meta = WEATHER[kind]
|
||||||
|
return h('div', { class: 'surface-card', style: 'margin-bottom:0;padding:9px' },
|
||||||
|
weatherArt(kind, 'mini'),
|
||||||
|
h('div', { style: 'margin-top:7px' },
|
||||||
|
h('strong', { style: 'display:block;font-size:11px;font-family:Fredoka,sans-serif' }, meta.title),
|
||||||
|
h('small', { style: 'display:block;font-size:9px;color:var(--muted);font-weight:700;line-height:1.2;margin-top:2px' }, meta.hint),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin: bouton ajouter
|
||||||
|
if (isAdmin()) {
|
||||||
|
children.push(
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-primary', style: 'width:100%;margin-top:16px',
|
||||||
|
onclick: () => openExamForm(undefined, rerender),
|
||||||
|
}, '+ Ajouter une évaluation'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
children.push(
|
||||||
|
h('div', { class: 'surface-card', style: 'border-color:var(--red)' },
|
||||||
|
h('p', { style: 'font-size:12px;font-weight:700;color:var(--red)' }, error),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(root, ...children.filter((c): c is Node => c !== null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === HERO CARD ==============================================================
|
||||||
|
|
||||||
function todayHero(onOpen: (exam: Exam) => void): HTMLElement {
|
function todayHero(onOpen: (exam: Exam) => void): HTMLElement {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const exams = examsByDay().get(dayKey(today)) ?? []
|
const exams = examsByDay().get(dayKey(today)) ?? []
|
||||||
|
const status = dayStatus(dayKey(today))
|
||||||
|
const meta = WEATHER[status]
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'relative w-full bg-gradient-to-br from-sky-600 via-cyan-600 to-teal-600 rounded-[2.2rem] p-6 text-white shadow-float overflow-hidden border border-sky-400/30' },
|
{ class: 'hero-card', style: `background:${heroGradient(status)}` },
|
||||||
h('div', { class: 'absolute -right-8 -bottom-8 w-44 h-44 bg-white/20 rounded-full blur-2xl' }),
|
h('div', { class: 'pill-badge' }, `Aujourd'hui • ${longDate(today)}`),
|
||||||
h(
|
h('div', { style: 'height:118px;position:relative;margin-top:-3px' }, weatherArt(status, 'hero')),
|
||||||
'div',
|
h('h2', { style: 'font-size:30px;line-height:.98;margin:5px 0 7px;font-family:Fredoka,sans-serif;font-weight:700' }, meta.title),
|
||||||
{ class: 'flex justify-between items-start relative z-10' },
|
h('div', { style: 'font-size:12px;font-weight:700;max-width:280px;opacity:.91' }, meta.hint),
|
||||||
h('span', { class: 'bg-black/30 backdrop-blur-md px-3.5 py-1 rounded-full text-xs font-black uppercase tracking-wider border border-white/10' },
|
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding-top:12px;border-top:1px solid rgba(255,255,255,.18)' },
|
||||||
longDate(today)),
|
h('span', { class: 'tag-badge' }, 'Aucun stress scolaire'),
|
||||||
h('span', { class: 'text-4xl' }, '☀️'),
|
h('span', { class: 'tag-badge' }, `${meta.icon} Événement spécial`),
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'mt-6 relative z-10' },
|
|
||||||
h('div', { class: 'inline-block bg-white/15 backdrop-blur-sm text-white text-xs font-black px-3 py-1 rounded-xl mb-2 border border-white/20' },
|
|
||||||
'GRAND SOLEIL'),
|
|
||||||
h('h2', { class: 'text-3xl font-black tracking-tight leading-none' }, 'Journée calme'),
|
|
||||||
h('p', { class: 'text-sky-50 font-semibold text-sm mt-1.5 opacity-90' },
|
|
||||||
'Aucune évaluation répertoriée. Profitez-en.'),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const main = exams.reduce((worst, e) =>
|
const main = exams.reduce((worst, e) => (e.weight > worst.weight ? e : worst), exams[0]!)
|
||||||
(e.weight > worst.weight ? e : worst), exams[0]!)
|
|
||||||
const meta = WEATHER[main.kind]
|
|
||||||
const gradients: Record<typeof main.kind, string> = {
|
|
||||||
soleil: 'from-sky-600 via-cyan-600 to-teal-600',
|
|
||||||
brouillard: 'from-purple-700 via-violet-600 to-slate-600',
|
|
||||||
vent: 'from-amber-600 via-orange-600 to-rose-500',
|
|
||||||
orage: 'from-rose-700 via-rose-600 to-orange-600',
|
|
||||||
tempete: 'from-red-700 via-rose-600 to-amber-600',
|
|
||||||
}
|
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
class: `relative w-full text-left bg-gradient-to-br ${gradients[main.kind]} rounded-[2.2rem] p-6 text-white shadow-float overflow-hidden border border-white/20 active:scale-[0.99] transition-transform`,
|
class: 'hero-card', style: `background:${heroGradient(main.kind)};width:100%;text-align:left;border:0;cursor:pointer`,
|
||||||
onclick: () => onOpen(main),
|
onclick: () => onOpen(main),
|
||||||
},
|
},
|
||||||
h('div', { class: 'absolute -right-8 -bottom-8 w-44 h-44 bg-yellow-300/20 rounded-full blur-2xl' }),
|
h('div', { class: 'pill-badge' }, `Aujourd'hui • ${formatTime(main.startsAt)}`),
|
||||||
h(
|
h('div', { style: 'height:118px;position:relative;margin-top:-3px' }, weatherArt(main.kind, 'hero')),
|
||||||
'div',
|
h('h2', { style: 'font-size:30px;line-height:.98;margin:5px 0 7px;font-family:Fredoka,sans-serif;font-weight:700' }, WEATHER[main.kind].title),
|
||||||
{ class: 'flex justify-between items-start relative z-10' },
|
h('div', { style: 'font-size:12px;font-weight:700;max-width:280px;opacity:.91' },
|
||||||
h('span', { class: 'bg-black/30 backdrop-blur-md px-3.5 py-1 rounded-full text-xs font-black uppercase tracking-wider border border-white/10' },
|
main.chapter ?? (exams.length > 1 ? `+ ${exams.length - 1} autre(s)` : WEATHER[main.kind].hint)),
|
||||||
`Aujourd'hui • ${formatTime(main.startsAt)}`),
|
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding-top:12px;border-top:1px solid rgba(255,255,255,.18)' },
|
||||||
h('span', { class: 'text-4xl' }, meta.icon),
|
h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap' },
|
||||||
|
main.room ? h('span', { class: 'tag-badge' }, `📍 ${main.room}`) : null,
|
||||||
|
h('span', { class: 'tag-badge' }, `⏱ ${formatDuration(main.durationMin)}`),
|
||||||
),
|
),
|
||||||
h(
|
h('span', { class: 'tag-badge', style: 'background:rgba(255,255,255,.25)' }, `Coeff ${main.weight.toFixed(1)}`),
|
||||||
'div',
|
|
||||||
{ class: 'mt-6 relative z-10' },
|
|
||||||
h('div', { class: 'inline-block bg-black/25 backdrop-blur-sm text-white text-xs font-black px-3 py-1 rounded-xl mb-2 border border-white/15' },
|
|
||||||
meta.title.toUpperCase()),
|
|
||||||
h('h2', { class: 'text-3xl font-black tracking-tight leading-none' }, main.subject),
|
|
||||||
h('p', { class: 'text-amber-50 font-semibold text-sm mt-1.5 opacity-90' },
|
|
||||||
main.chapter ?? (exams.length > 1 ? `+ ${exams.length - 1} autre(s) évaluation(s)` : meta.hint)),
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'mt-6 pt-4 border-t border-white/15 flex justify-between items-center relative z-10 text-xs font-bold' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex items-center gap-2' },
|
|
||||||
main.room ? h('span', { class: 'bg-white/20 px-3 py-1.5 rounded-xl' },
|
|
||||||
h('i', { class: 'fa-solid fa-door-open mr-1.5' }), main.room) : null,
|
|
||||||
h('span', { class: 'bg-white/20 px-3 py-1.5 rounded-xl' },
|
|
||||||
h('i', { class: 'fa-solid fa-clock mr-1.5' }), formatDuration(main.durationMin)),
|
|
||||||
),
|
|
||||||
h('span', { class: 'bg-white text-slate-900 px-3 py-1.5 rounded-xl font-black shadow-sm' },
|
|
||||||
`Coeff ${main.weight.toFixed(1)}`),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Frise horizontale des 7 prochains jours. */
|
// === MODALE DÉTAIL ===========================================================
|
||||||
function weekStrip(onAdminDay: (dayKey: string) => void): HTMLElement {
|
|
||||||
const { stripDays, selectedDay } = getState()
|
|
||||||
const today = new Date()
|
|
||||||
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex gap-2 overflow-x-auto no-scrollbar -mx-5 px-5 pb-1' },
|
|
||||||
...stripDays.map((date) => {
|
|
||||||
const key = dayKey(date)
|
|
||||||
const status = dayStatus(key)
|
|
||||||
const count = examsByDay().get(key)?.length ?? 0
|
|
||||||
const isToday = isSameDay(date, today)
|
|
||||||
const active = selectedDay === key
|
|
||||||
const overridden = isDayOverridden(key)
|
|
||||||
|
|
||||||
return h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
class: `shrink-0 w-[3.75rem] rounded-2xl border p-2 flex flex-col items-center gap-1 transition-all active:scale-95 ${
|
|
||||||
active
|
|
||||||
? 'bg-slate-700/60 border-sky-400/60'
|
|
||||||
: 'bg-slate-800/70 border-slate-700/60'
|
|
||||||
}`,
|
|
||||||
onclick: () => {
|
|
||||||
if (isAdmin()) onAdminDay(key)
|
|
||||||
else selectDay(key)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h('span', { class: `text-[9px] font-black tracking-wider ${isToday ? 'text-sky-400' : 'text-slate-500'}` },
|
|
||||||
isToday ? "AUJ." : dayNameShort(date)),
|
|
||||||
h('span', { class: 'text-lg font-black text-slate-100 leading-none' }, dayNumber(date)),
|
|
||||||
h('span', { class: 'text-base leading-none' }, WEATHER[status].icon),
|
|
||||||
h('span', { class: 'text-[9px] font-bold text-slate-500' },
|
|
||||||
count === 0 ? '—' : `${count} éval.`),
|
|
||||||
overridden
|
|
||||||
? h('span', { class: 'text-[7px] font-black text-sky-400 uppercase' }, 'forcé')
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bandeau flash info du jour (message rédigé par l'admin). */
|
|
||||||
function flashBanner(): HTMLElement | null {
|
|
||||||
const flash = currentFlash()
|
|
||||||
if (!flash?.message) return null
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'bg-gradient-to-r from-sky-500/15 to-indigo-500/15 border border-sky-400/25 rounded-2xl p-3.5 flex items-start gap-2.5' },
|
|
||||||
h('span', { class: 'text-lg leading-none mt-0.5' }, '📻'),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex-1' },
|
|
||||||
h('p', { class: 'text-[10px] font-black uppercase tracking-wider text-sky-400 mb-0.5' }, 'Flash info du jour'),
|
|
||||||
h('p', { class: 'text-xs font-bold text-slate-200 leading-relaxed' }, flash.message),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Modale admin : forcer la météo d'un jour. */
|
|
||||||
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 current = dayStatus(dayKeyValue)
|
|
||||||
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]
|
|
||||||
const active = current === kind && isDayOverridden(dayKeyValue)
|
|
||||||
return h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
class: `flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
|
|
||||||
active ? meta.badge : '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) {
|
|
||||||
setError(error instanceof Error ? error.message : 'Erreur')
|
|
||||||
showToast('Erreur lors de la mise à jour', '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 active:scale-95 transition-transform',
|
|
||||||
onclick: async () => {
|
|
||||||
try {
|
|
||||||
await clearDayWeather(dayKeyValue)
|
|
||||||
showToast('Override météo retiré', 'info')
|
|
||||||
close()
|
|
||||||
rerender()
|
|
||||||
} catch (error) {
|
|
||||||
setError(error instanceof Error ? error.message : 'Erreur')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h('i', { class: 'fa-solid fa-rotate-left mr-1.5' }),
|
|
||||||
'Retirer l’override (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 hover:text-slate-200 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Liste groupée par jour, filtrée ou non par la frise. */
|
|
||||||
function dayList(onOpen: (exam: Exam) => void): HTMLElement {
|
|
||||||
const { selectedDay, stripDays } = getState()
|
|
||||||
const visibleKeys = selectedDay ? [selectedDay] : stripDays.map(dayKey)
|
|
||||||
const grouped = examsByDay()
|
|
||||||
|
|
||||||
const sections = visibleKeys
|
|
||||||
.map((key) => {
|
|
||||||
const exams = grouped.get(key) ?? []
|
|
||||||
if (exams.length === 0) return null
|
|
||||||
const [y, m, d] = key.split('-').map(Number)
|
|
||||||
const date = new Date(y!, (m ?? 1) - 1, d ?? 1)
|
|
||||||
return { key, date, exams }
|
|
||||||
})
|
|
||||||
.filter((s): s is { key: string; date: Date; exams: Exam[] } => s !== null)
|
|
||||||
|
|
||||||
if (sections.length === 0) {
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'text-center py-10 px-6' },
|
|
||||||
h('div', { class: 'text-4xl mb-3' }, WEATHER[dayStatus(selectedDay ?? dayKey(new Date()))].icon),
|
|
||||||
h('p', { class: 'text-sm font-black text-slate-200' },
|
|
||||||
selectedDay ? 'Grand Soleil' : 'Aucune évaluation sur les 7 jours'),
|
|
||||||
h('p', { class: 'text-xs text-slate-400 font-semibold mt-1' },
|
|
||||||
selectedDay ? 'Rien de prévu ce jour-là.' : 'Profitez du calme, ou ajoutez une évaluation si vous êtes admin.'),
|
|
||||||
selectedDay
|
|
||||||
? h('button', { class: 'mt-4 text-xs font-black text-sky-400', onclick: () => selectDay(null) },
|
|
||||||
'Voir toute la semaine')
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'space-y-4' },
|
|
||||||
...sections.map(({ key, date, exams }) =>
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'space-y-2.5' },
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'flex items-center gap-2.5 pt-1' },
|
|
||||||
h('span', { class: 'text-sm font-black text-slate-200' }, shortDate(date)),
|
|
||||||
weatherBadge(dayStatus(key)),
|
|
||||||
),
|
|
||||||
...exams.map((exam) => examCard(exam, onOpen)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Modale plein écran avec la fiche détaillée et les actions admin. */
|
|
||||||
function openDetail(exam: Exam, rerender: () => void): void {
|
function openDetail(exam: Exam, rerender: () => void): void {
|
||||||
const overlay = h('div', {
|
const overlay = h('div', { class: 'modal-overlay' })
|
||||||
class:
|
|
||||||
'fixed inset-0 z-[90] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-6',
|
|
||||||
})
|
|
||||||
|
|
||||||
const close = (): void => overlay.remove()
|
const close = (): void => overlay.remove()
|
||||||
|
const meta = WEATHER[exam.kind]
|
||||||
|
|
||||||
|
const row = (icon: string, label: string, value: string | null | undefined): HTMLElement | null =>
|
||||||
|
value ? h('div', { style: 'display:flex;align-items:flex-start;gap:12px;padding:8px 0' },
|
||||||
|
h('span', { style: 'color:var(--muted);width:16px;text-align:center;margin-top:2px' }, icon),
|
||||||
|
h('div', {},
|
||||||
|
h('p', { style: 'font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0' }, label),
|
||||||
|
h('p', { style: 'font-size:14px;font-weight:800;color:var(--text);word-break:break-word;margin:2px 0 0' }, value),
|
||||||
|
),
|
||||||
|
) : null
|
||||||
|
|
||||||
const panel = h(
|
const panel = h(
|
||||||
'div',
|
'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-[88vh] overflow-y-auto no-scrollbar shadow-2xl' },
|
{ class: 'modal-panel' },
|
||||||
h(
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px' },
|
||||||
'div',
|
h('div', { class: 'pill-badge' }, 'Fiche d’examen'),
|
||||||
{ class: 'flex items-start justify-between gap-3 mb-4' },
|
h('button', { style: 'background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer', onclick: close }, '✕'),
|
||||||
h('span', { class: 'text-[10px] font-black uppercase tracking-widest text-slate-500' }, 'Fiche d’examen'),
|
|
||||||
h('button', { class: 'text-slate-400 hover:text-slate-200 text-lg', onclick: close },
|
|
||||||
h('i', { class: 'fa-solid fa-xmark' })),
|
|
||||||
),
|
),
|
||||||
examDetail(exam),
|
h('div', { style: 'display:flex;align-items:center;gap:14px;padding-bottom:14px;border-bottom:1px solid var(--border)' },
|
||||||
|
weatherArt(exam.kind, 'mini'),
|
||||||
|
h('div', {},
|
||||||
|
h('h2', { style: 'font-size:20px;font-family:Fredoka,sans-serif;font-weight:700;margin:0;color:var(--text)' }, exam.subject),
|
||||||
|
h('p', { style: `font-size:11px;font-weight:900;text-transform:uppercase;color:${meta.hex};margin:2px 0 0` }, meta.title),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
row('👤', 'Enseignant', exam.teacher),
|
||||||
|
row('📍', 'Salle', exam.room),
|
||||||
|
row('📖', 'Chapitre', exam.chapter),
|
||||||
|
row('📅', 'Date', longDate(new Date(exam.startsAt))),
|
||||||
|
row('🕐', 'Heure', formatTime(exam.startsAt)),
|
||||||
|
row('⏱', 'Durée', formatDuration(exam.durationMin)),
|
||||||
|
row('📊', 'Coefficient', exam.weight > 0 ? exam.weight.toFixed(1) : '—'),
|
||||||
|
row('📝', 'Note', exam.note),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isAdmin()) {
|
if (isAdmin()) {
|
||||||
panel.append(
|
panel.append(
|
||||||
h(
|
h('div', { style: 'display:flex;gap:8px;margin-top:16px;padding-top:14px;border-top:1px solid var(--border)' },
|
||||||
'div',
|
h('button', {
|
||||||
{ class: 'flex gap-2 mt-5 pt-4 border-t border-slate-700/60' },
|
class: 'btn-secondary', style: 'flex:1',
|
||||||
h(
|
onclick: () => { close(); openExamForm(exam, rerender) },
|
||||||
'button',
|
}, '✏️ Modifier'),
|
||||||
{
|
h('button', {
|
||||||
class: 'flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
|
class: 'btn-secondary', style: 'flex:1;border-color:var(--red);color:var(--red)',
|
||||||
onclick: () => {
|
|
||||||
close()
|
|
||||||
openExamForm(exam, rerender)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
h('i', { class: 'fa-solid fa-pen mr-1.5' }), 'Modifier',
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'button',
|
|
||||||
{
|
|
||||||
class: 'flex-1 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: async () => {
|
onclick: async () => {
|
||||||
close()
|
close()
|
||||||
try {
|
try { await removeExam(exam.id); showToast('Supprimée', 'success'); rerender() }
|
||||||
await removeExam(exam.id)
|
catch (e) { setError(e instanceof Error ? e.message : 'Erreur') }
|
||||||
showToast('Évaluation supprimée', 'success')
|
|
||||||
rerender()
|
|
||||||
} catch (error) {
|
|
||||||
setError(error instanceof Error ? error.message : 'Suppression impossible')
|
|
||||||
showToast('Suppression impossible', 'reject')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
}, '🗑 Supprimer'),
|
||||||
h('i', { class: 'fa-solid fa-trash mr-1.5' }), 'Supprimer',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
overlay.append(panel)
|
overlay.append(panel)
|
||||||
overlay.addEventListener('click', (event) => {
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||||
if (event.target === overlay) close()
|
|
||||||
})
|
|
||||||
document.body.append(overlay)
|
document.body.append(overlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderWeek(root: HTMLElement, rerender: () => void): void {
|
// === MODALE MÉTÉO PAR JOUR ===================================================
|
||||||
const { error, session } = getState()
|
|
||||||
const open = (exam: Exam): void => openDetail(exam, rerender)
|
|
||||||
const openAdminDay = (dk: string): void => openDayWeatherModal(dk, rerender)
|
|
||||||
|
|
||||||
const children: (Node | null)[] = [
|
function openDayWeatherModal(dayKeyValue: string, rerender: () => void): void {
|
||||||
flashBanner(),
|
const overlay = h('div', { class: 'modal-overlay' })
|
||||||
todayHero(open),
|
const close = (): void => overlay.remove()
|
||||||
h(
|
const [y, m, d] = dayKeyValue.split('-').map(Number)
|
||||||
'div',
|
const date = new Date(y!, (m ?? 1) - 1, d ?? 1)
|
||||||
{ class: 'flex justify-between items-center pt-2' },
|
|
||||||
h('h3', { class: 'text-base font-extrabold text-slate-200 uppercase tracking-wider' }, 'La carte de la semaine'),
|
const panel = h('div', { class: 'modal-panel' },
|
||||||
h('span', { class: 'text-xs font-bold text-sky-400' },
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px' },
|
||||||
isAdmin() ? 'Tapez un jour pour forcer la météo' : '7 prochains jours'),
|
h('div', {},
|
||||||
|
h('h2', { style: 'font-size:18px;font-family:Fredoka,sans-serif;font-weight:700;margin:0;color:var(--text)' }, 'Météo du jour'),
|
||||||
|
h('p', { style: 'font-size:12px;color:var(--muted);font-weight:700;margin:2px 0 0' }, longDate(date)),
|
||||||
),
|
),
|
||||||
weekStrip(openAdminDay),
|
h('button', { style: 'background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer', onclick: close }, '✕'),
|
||||||
]
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
children.push(
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ class: 'bg-rose-500/10 border border-rose-500/25 rounded-2xl p-3.5 flex items-center gap-2.5' },
|
|
||||||
h('i', { class: 'fa-solid fa-triangle-exclamation text-rose-400' }),
|
|
||||||
h('p', { class: 'text-xs font-bold text-rose-200 flex-1' }, error),
|
|
||||||
),
|
),
|
||||||
)
|
h('div', { style: 'display:grid;gap:8px' },
|
||||||
}
|
...WEATHER_KINDS.map((kind) => {
|
||||||
|
const meta = WEATHER[kind]
|
||||||
children.push(dayList(open))
|
return h('button', {
|
||||||
|
class: 'day-row',
|
||||||
if (isAdmin()) {
|
onclick: async () => {
|
||||||
children.push(
|
try { await setDayWeather(dayKeyValue, kind); showToast(`Météo forcée : ${meta.label}`, 'success'); close(); rerender() }
|
||||||
h(
|
catch (e) { showToast(e instanceof Error ? e.message : 'Erreur', 'reject') }
|
||||||
'button',
|
|
||||||
{
|
|
||||||
class:
|
|
||||||
'w-full bg-sky-500/10 border border-sky-500/25 text-sky-300 text-sm font-extrabold py-3.5 rounded-2xl active:scale-[0.98] transition-transform',
|
|
||||||
onclick: () => openExamForm(undefined, rerender),
|
|
||||||
},
|
},
|
||||||
h('i', { class: 'fa-solid fa-plus mr-2' }), 'Ajouter une évaluation',
|
},
|
||||||
|
weatherArt(kind, 'mini'),
|
||||||
|
h('div', {},
|
||||||
|
h('strong', { style: 'display:block;font-size:14px;font-family:Fredoka,sans-serif' }, meta.title),
|
||||||
|
h('small', { style: 'font-size:10px;color:var(--muted);font-weight:700' }, meta.hint),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}),
|
||||||
|
isDayOverridden(dayKeyValue)
|
||||||
|
? h('button', {
|
||||||
|
class: 'btn-secondary', style: 'margin-top:8px',
|
||||||
|
onclick: async () => {
|
||||||
|
try { await clearDayWeather(dayKeyValue); showToast('Override retiré', 'info'); close(); rerender() }
|
||||||
|
catch (e) { showToast(e instanceof Error ? e.message : 'Erreur', 'reject') }
|
||||||
|
},
|
||||||
|
}, 'Retirer l’override (météo auto)')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
overlay.append(panel)
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||||
|
document.body.append(overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === FORMULAIRE ÉVALUATION ===================================================
|
||||||
|
|
||||||
|
function openExamForm(exam: Exam | undefined, rerender: () => void): void {
|
||||||
|
const overlay = h('div', { class: 'modal-overlay' })
|
||||||
|
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: 'app-input', value: exam?.subject ?? '', placeholder: 'Mathématiques' })
|
||||||
|
const teacher = h('input', { class: 'app-input', value: exam?.teacher ?? '' })
|
||||||
|
const room = h('input', { class: 'app-input', value: exam?.room ?? '' })
|
||||||
|
const chapter = h('input', { class: 'app-input', value: exam?.chapter ?? '' })
|
||||||
|
const day = h('input', { type: 'date', class: 'app-input', value: exam ? isoDay(new Date(exam.startsAt)) : isoDay(now) })
|
||||||
|
const time = h('input', { type: 'time', class: 'app-input', 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: 'app-input', value: String(exam?.durationMin ?? 60), min: '5', step: '5' })
|
||||||
|
const weight = h('input', { type: 'number', class: 'app-input', value: String(exam?.weight ?? 2), min: '0', max: '20', step: '0.5' })
|
||||||
|
const note = h('textarea', { class: 'app-input', style: 'min-height:50px;resize:none' })
|
||||||
|
note.value = exam?.note ?? ''
|
||||||
|
|
||||||
|
let kind: WeatherKind = exam?.kind ?? 'pluie'
|
||||||
|
let kindOverride = false
|
||||||
|
const kindPreview = h('div', { style: 'display:flex;flex-wrap:wrap;gap:6px' })
|
||||||
|
|
||||||
|
const renderKinds = (): void => {
|
||||||
|
kindPreview.replaceChildren(
|
||||||
|
...WEATHER_KINDS.map((k) => {
|
||||||
|
const meta = WEATHER[k]
|
||||||
|
const active = kind === k
|
||||||
|
return h('button', {
|
||||||
|
type: 'button',
|
||||||
|
style: `padding:5px 8px;border-radius:8px;font-size:10px;font-weight:900;border:1px solid ${active ? meta.hex : 'var(--border)'};background:${active ? `color-mix(in srgb,${meta.hex} 15%,var(--surface2))` : 'var(--surface2)'};color:${active ? meta.hex : 'var(--muted)'};cursor:pointer`,
|
||||||
|
onclick: () => { kind = k; kindOverride = true; renderKinds() },
|
||||||
|
}, `${meta.icon} ${meta.label}`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
children.push(
|
weight.addEventListener('input', () => {
|
||||||
h(
|
const v = Number(weight.value)
|
||||||
'p',
|
if (Number.isFinite(v) && !kindOverride) {
|
||||||
{ class: 'text-[10px] text-slate-600 font-semibold text-center pt-2' },
|
kind = v <= 0 ? 'soleil' : v < 1 ? 'nuageux' : v < 2 ? 'pluie' : v < 4 ? 'orage' : v < 5 ? 'tempete' : 'fin_du_monde'
|
||||||
session ? `${session.klass.name} · ${getState().exams.length} évaluation(s) sur la période` : '',
|
renderKinds()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
renderKinds()
|
||||||
|
|
||||||
|
const errorBox = h('p', { style: 'font-size:12px;font-weight:700;color:#FB7185;display:none' })
|
||||||
|
const showError = (msg: string): void => { errorBox.textContent = msg; errorBox.style.display = 'block' }
|
||||||
|
|
||||||
|
const submitBtn = h('button', {
|
||||||
|
class: 'btn-primary', style: 'flex:1',
|
||||||
|
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.style.opacity = '0.6'
|
||||||
|
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' : 'Ajoutée', 'success')
|
||||||
|
close(); rerender()
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : 'Erreur')
|
||||||
|
submitBtn.disabled = false
|
||||||
|
submitBtn.style.opacity = '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, editing ? 'Enregistrer' : 'Ajouter')
|
||||||
|
|
||||||
|
const panel = h('div', { class: 'modal-panel' },
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px' },
|
||||||
|
h('div', {},
|
||||||
|
h('h2', { style: 'font-size:18px;font-family:Fredoka,sans-serif;font-weight:700;margin:0;color:var(--text)' },
|
||||||
|
editing ? 'Modifier l’évaluation' : 'Nouvelle évaluation'),
|
||||||
|
h('p', { style: 'font-size:12px;color:var(--muted);font-weight:700;margin:2px 0 0' }, 'Le statut météo découle du coefficient.'),
|
||||||
|
),
|
||||||
|
h('button', { style: 'background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer', onclick: close }, '✕'),
|
||||||
|
),
|
||||||
|
h('div', { style: 'display:grid;gap:12px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Matière'), subject),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Enseignant'), teacher),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Salle'), room)),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Chapitre'), chapter),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Date'), day),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Heure'), time)),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px' },
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Durée (min)'), duration),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Coefficient'), weight)),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Statut météo'), kindPreview),
|
||||||
|
h('div', {}, h('span', { class: 'app-label' }, 'Note'), note),
|
||||||
|
errorBox,
|
||||||
|
h('div', { style: 'display:flex;gap:8px' },
|
||||||
|
h('button', { class: 'btn-secondary', onclick: close }, 'Annuler'),
|
||||||
|
submitBtn),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
mount(root, ...children.filter((c): c is Node => c !== null))
|
overlay.append(panel)
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||||
|
document.body.append(overlay)
|
||||||
|
subject.focus()
|
||||||
}
|
}
|
||||||
|
|
|
||||||