From 246f989bdca168ea9a871f87d4ea9df5f527906c Mon Sep 17 00:00:00 2001 From: Mathis Date: Sun, 4 Oct 2026 17:05:56 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20config=20admin=20(m=C3=A9t?= =?UTF-8?q?=C3=A9o=20forc=C3=A9e,=20flash=20info,=20=C3=A9dition=20classe)?= =?UTF-8?q?=20+=20PWA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB : table day_weather (override admin par jour), flash_message sur classes - API : PUT/DELETE /api/config/day-weather, PUT /api/config/flash, PATCH /api/config/class - SSE : day_weather.updated/deleted, flash.updated, class.updated - Front : bandeau flash info, indicateur 'forcé' sur la frise, modale météo admin - Onglet Plus : panneau admin (flash info + édition classe), tout admin modifiable - PWA : manifest.webmanifest, service worker, meta Apple, icônes 192/512 - Web version accessible sur iOS via Safari (Ajouter à l'écran d'accueil) --- scripts/gen-icons.mjs | 108 +++++++++++++++++ server/src/auth.ts | 10 +- server/src/db.ts | 26 ++++- server/src/index.ts | 2 + server/src/routes/config.ts | 144 +++++++++++++++++++++++ server/src/seed.ts | 4 +- shared/src/index.ts | 22 ++++ web/index.html | 20 +++- web/public/icon-192.png | Bin 0 -> 3151 bytes web/public/icon-512.png | Bin 0 -> 10931 bytes web/public/manifest.webmanifest | 31 +++++ web/public/sw.js | 39 +++++++ web/src/api.ts | 21 +++- web/src/store.ts | 114 +++++++++++++++--- web/src/views/plus.ts | 201 +++++++++++++++++++++++--------- web/src/views/week.ts | 126 +++++++++++++++++++- 16 files changed, 782 insertions(+), 86 deletions(-) create mode 100644 scripts/gen-icons.mjs create mode 100644 server/src/routes/config.ts create mode 100644 web/public/icon-192.png create mode 100644 web/public/icon-512.png create mode 100644 web/public/manifest.webmanifest create mode 100644 web/public/sw.js diff --git a/scripts/gen-icons.mjs b/scripts/gen-icons.mjs new file mode 100644 index 0000000..305ce59 --- /dev/null +++ b/scripts/gen-icons.mjs @@ -0,0 +1,108 @@ +/** + * Génère des icônes PWA simples (dégradé bleu + pastille blanche). + * Usage : node scripts/gen-icons.mjs + */ +import { writeFileSync, mkdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { deflateSync } from 'node:zlib' + +const OUT = resolve(import.meta.dirname, '../web/public') +mkdirSync(OUT, { recursive: true }) + +function crc32(buf) { + const table = [] + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + table[n] = c >>> 0 + } + let crc = 0xffffffff + for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8) + return (crc ^ 0xffffffff) >>> 0 +} + +function chunk(type, data) { + const len = Buffer.alloc(4) + len.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(body)) + return Buffer.concat([len, body, crc]) +} + +function encodePng(width, height, rgba) { + const raw = Buffer.alloc(height * (1 + width * 4)) + for (let y = 0; y < height; y++) { + raw[y * (1 + width * 4)] = 0 + rgba.copy(raw, y * (1 + width * 4) + 1, y * width * 4, (y + 1) * width * 4) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 6 + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +function drawIcon(size) { + const buf = Buffer.alloc(size * size * 4) + const c0 = [56, 189, 248] + const c1 = [99, 102, 241] + const cx = size / 2 + const cy = size / 2 + const rInner = size * 0.3 + const rPad = size * 0.16 + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + 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 + let transparent = false + 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) { + buf[i + 3] = 0 + continue + } + + const t = (x + y) / (size * 2) + let r = c0[0] + (c1[0] - c0[0]) * t + let g = c0[1] + (c1[1] - c0[1]) * t + 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) + buf[i + 1] = Math.round(g) + buf[i + 2] = Math.round(b) + buf[i + 3] = 255 + } + } + return buf +} + +for (const size of [192, 512]) { + const png = encodePng(size, size, drawIcon(size)) + writeFileSync(resolve(OUT, `icon-${size}.png`), png) + console.log(`icon-${size}.png (${png.length} octets)`) +} diff --git a/server/src/auth.ts b/server/src/auth.ts index 3470486..d1f6ca8 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -11,6 +11,7 @@ interface MemberRow { role: Role class_name: string class_level: string | null + class_flash_message: string | null } export const newToken = (): string => randomBytes(32).toString('base64url') @@ -51,7 +52,7 @@ export function createClass(name: string, level: string | null, nickname: string return { token, member: { id: memberId, classId, nickname, role: 'admin' }, - klass: { id: classId, name, level }, + klass: { id: classId, name, level, flashMessage: null }, } }) return { code, ...create() } @@ -71,7 +72,7 @@ export function joinClass(code: string, nickname: string): NewSession | null { return { token, member: { id: memberId, classId: row.id, nickname, role: 'student' }, - klass: { id: row.id, name: row.name, level: row.level }, + klass: { id: row.id, name: row.name, level: row.level, flashMessage: null }, } } @@ -79,7 +80,8 @@ export function findSession(token: string): { member: Member; klass: ClassInfo } const row = db .prepare<[string], MemberRow>( `SELECT m.id, m.class_id, m.nickname, m.role, - c.name AS class_name, c.level AS class_level + c.name AS class_name, c.level AS class_level, + c.flash_message AS class_flash_message FROM members m JOIN classes c ON c.id = m.class_id WHERE m.token = ?`, @@ -88,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo } if (!row) return null return { member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role }, - klass: { id: row.class_id, name: row.class_name, level: row.class_level }, + klass: { id: row.class_id, name: row.class_name, level: row.class_level, flashMessage: row.class_flash_message }, } } diff --git a/server/src/db.ts b/server/src/db.ts index e5547d7..4f6c146 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -13,11 +13,12 @@ db.pragma('foreign_keys = ON') db.exec(` CREATE TABLE IF NOT EXISTS classes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - level TEXT, - code TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + level TEXT, + code TEXT NOT NULL UNIQUE, + flash_message TEXT, + created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS members ( @@ -50,8 +51,23 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS idx_exams_class_start ON exams(class_id, starts_at); + + CREATE TABLE IF NOT EXISTS day_weather ( + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + day_key TEXT NOT NULL, + kind TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (class_id, day_key) + ); `) +// Migration légère : ajouter flash_message si la table existe déjà sans. +try { + db.exec(`ALTER TABLE classes ADD COLUMN flash_message TEXT`) +} catch { + // colonne déjà présente : rien à faire +} + export const nowIso = (): string => new Date().toISOString() /** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */ diff --git a/server/src/index.ts b/server/src/index.ts index 140ce63..adcbbdb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { classesRouter } from './routes/classes.js' import { examsRouter } from './routes/exams.js' import { streamRouter } from './routes/stream.js' +import { configRouter } from './routes/config.js' const here = dirname(fileURLToPath(import.meta.url)) const PORT = Number(process.env['PORT'] ?? 8787) @@ -42,6 +43,7 @@ app.get('/api/health', (_req, res) => { app.use('/api/classes', classesRouter) app.use('/api/exams', examsRouter) app.use('/api/stream', streamRouter) +app.use('/api/config', configRouter) app.use('/api', (_req, res) => { res.status(404).json({ error: 'Route inconnue' }) diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts new file mode 100644 index 0000000..67938bf --- /dev/null +++ b/server/src/routes/config.ts @@ -0,0 +1,144 @@ +import { Router } from 'express' +import { z } from 'zod' +import type { ClassInfo, DayWeather, FlashInfo, WeatherKind } from '@meteo-exam/shared' +import { isWeatherKind } from '@meteo-exam/shared' +import { db, nowIso } from '../db.js' +import { requireSession, requireAdmin } from '../auth.js' +import { broadcast } from '../events.js' + +export const configRouter = Router() + +// --- Override météo par jour ------------------------------------------------- + +const dayWeatherSchema = z.object({ + dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format AAAA-MM-JJ attendu'), + kind: z.string().refine(isWeatherKind, 'Statut météo inconnu'), +}) + +/** Tous les overrides météo de la classe. */ +configRouter.get('/day-weather', requireSession, (req, res) => { + const classId = req.session!.member.classId + const rows = db + .prepare<[number], { day_key: string; kind: WeatherKind; updated_at: string }>( + 'SELECT day_key, kind, updated_at FROM day_weather WHERE class_id = ? ORDER BY day_key ASC', + ) + .all(classId) + res.json( + rows.map((r) => ({ + classId, + dayKey: r.day_key, + kind: r.kind, + updatedAt: r.updated_at, + })) satisfies DayWeather[], + ) +}) + +/** Pose (ou remplace) l'override météo d'un jour. */ +configRouter.put('/day-weather', requireSession, requireAdmin, (req, res) => { + const parsed = dayWeatherSchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' }) + return + } + const { dayKey, kind } = parsed.data + const classId = req.session!.member.classId + const now = nowIso() + + db.prepare( + `INSERT INTO day_weather (class_id, day_key, kind, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(class_id, day_key) DO UPDATE SET kind = excluded.kind, updated_at = excluded.updated_at`, + ).run(classId, dayKey, kind, now) + + const dayWeather: DayWeather = { classId, dayKey, kind, updatedAt: now } + broadcast(classId, { type: 'day_weather.updated', dayWeather }) + res.json(dayWeather) +}) + +/** Supprime l'override d'un jour (la météo redevient celle des évaluations). */ +configRouter.delete('/day-weather/:dayKey', requireSession, requireAdmin, (req, res) => { + const raw = req.params['dayKey'] + const dayKey = Array.isArray(raw) ? raw[0] : raw + if (!dayKey) { + res.status(400).json({ error: 'Jour manquant' }) + return + } + const classId = req.session!.member.classId + const info = db + .prepare('DELETE FROM day_weather WHERE class_id = ? AND day_key = ?') + .run(classId, dayKey) + if (info.changes === 0) { + res.status(404).json({ error: 'Aucun override pour ce jour' }) + return + } + broadcast(classId, { type: 'day_weather.deleted', dayKey }) + res.status(204).end() +}) + +// --- Flash info -------------------------------------------------------------- + +const flashSchema = z.object({ + message: z.string().trim().max(500, 'Message trop long (500 caractères max)'), +}) + +configRouter.get('/flash', requireSession, (req, res) => { + const row = db + .prepare<[number], { flash_message: string | null }>( + 'SELECT flash_message FROM classes WHERE id = ?', + ) + .get(req.session!.member.classId) + const message = row?.flash_message ?? null + res.json({ classId: req.session!.member.classId, message, updatedAt: '' } satisfies FlashInfo) +}) + +/** Écrit (ou efface avec message vide) le flash info du jour. */ +configRouter.put('/flash', requireSession, requireAdmin, (req, res) => { + const parsed = flashSchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' }) + return + } + const classId = req.session!.member.classId + const message = (parsed.data.message || '').trim() || null + const now = nowIso() + + db.prepare('UPDATE classes SET flash_message = ? WHERE id = ?').run(message ?? '', classId) + + const flash: FlashInfo = { classId, message, updatedAt: now } + broadcast(classId, { type: 'flash.updated', flash }) + res.json(flash) +}) + +// --- Édition de la classe ---------------------------------------------------- + +const classSchema = z.object({ + name: z.string().trim().min(2, 'Nom trop court').max(60), + level: z.string().trim().max(40).nullish(), +}) + +configRouter.patch('/class', requireSession, requireAdmin, (req, res) => { + const parsed = classSchema.safeParse(req.body) + if (!parsed.success) { + res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' }) + return + } + const classId = req.session!.member.classId + const { name, level } = parsed.data + + db.prepare('UPDATE classes SET name = ?, level = ? WHERE id = ?').run(name, level ?? '', classId) + + const row = db + .prepare<[number], { id: number; name: string; level: string | null; flash_message: string | null }>( + 'SELECT id, name, level, flash_message FROM classes WHERE id = ?', + ) + .get(classId)! + + const klass: ClassInfo = { + id: row.id, + name: row.name, + level: row.level, + flashMessage: row.flash_message, + } + broadcast(classId, { type: 'class.updated', klass }) + res.json(klass) +}) diff --git a/server/src/seed.ts b/server/src/seed.ts index 349ff7c..5df9dc9 100644 --- a/server/src/seed.ts +++ b/server/src/seed.ts @@ -83,8 +83,8 @@ const exams: SeedExam[] = [ ] const seed = db.transaction(() => { - db.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)') - .run(CLASS_NAME, LEVEL, CLASS_CODE, nowIso()) + db.prepare('INSERT INTO classes (name, level, code, flash_message, created_at) VALUES (?, ?, ?, ?, ?)') + .run(CLASS_NAME, LEVEL, CLASS_CODE, 'Bon courage pour les révisions cette semaine ! 🌤️', nowIso()) const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?') .get(CLASS_CODE)!.id diff --git a/shared/src/index.ts b/shared/src/index.ts index 155a95e..f33a293 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -146,6 +146,8 @@ export interface ClassInfo { id: number name: string level: string | null + /** Message d'annonce du jour (flash info), lu par tous les membres. */ + flashMessage: string | null } export interface Member { @@ -165,11 +167,31 @@ export interface ApiError { error: string } +/** Override météo posé par l'admin pour un jour donné (clé locale AAAA-MM-JJ). */ +export interface DayWeather { + classId: number + dayKey: string + kind: WeatherKind + updatedAt: string +} + +/** Flash info : message quotidien rédigé par l'admin. */ +export interface FlashInfo { + classId: number + /** null = pas de message publié. */ + message: string | null + updatedAt: string +} + /** Payloads émis par le flux SSE. */ export type StreamEvent = | { type: 'exam.created'; exam: Exam } | { type: 'exam.updated'; exam: Exam } | { type: 'exam.deleted'; examId: number } + | { type: 'day_weather.updated'; dayWeather: DayWeather } + | { type: 'day_weather.deleted'; dayKey: string } + | { type: 'flash.updated'; flash: FlashInfo | null } + | { type: 'class.updated'; klass: ClassInfo } | { type: 'ping' } export const CLASS_CODE_LENGTH = 6 diff --git a/web/index.html b/web/index.html index 325a6ed..fb16f0f 100644 --- a/web/index.html +++ b/web/index.html @@ -2,9 +2,20 @@ - + Météo Exam + + + + + + + + + + +
+ diff --git a/web/public/icon-192.png b/web/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..d56f89e3a213def26b0e5a6c603b1204c278c203 GIT binary patch literal 3151 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 i0CjQqtv>~B>u^vs{aRZ$nEC< literal 0 HcmV?d00001 diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..5c7a5c5 --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,31 @@ +{ + "name": "Météo Exam", + "short_name": "Météo Exam", + "description": "Le calendrier des évaluations scolaires, en bulletin météo interactif.", + "start_url": "/", + "display": "standalone", + "background_color": "#020617", + "theme_color": "#020617", + "orientation": "portrait", + "lang": "fr", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..78dc714 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,39 @@ +/** + * Service worker Météo Exam. + * - Cache les assets statiques (app shell) pour un démarrage hors ligne. + * - Les appels API passent en network-first : les données restent fraîches. + */ + +const CACHE_NAME = 'meteo-exam-v2' +const STATIC_ASSETS = ['/', '/index.html', '/manifest.webmanifest'] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)), + ) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))), + ), + ) + self.clients.claim() +}) + +self.addEventListener('fetch', (event) => { + const { request } = event + const url = new URL(request.url) + + // API et flux SSE : toujours réseau, pas de cache. + if (url.pathname.startsWith('/api/')) return + + // Assets statiques : cache d'abord, réseau en secours. + if (request.method === 'GET' && (url.origin === self.location.origin || url.hostname === 'localhost')) { + event.respondWith( + caches.match(request).then((cached) => cached ?? fetch(request).catch(() => caches.match('/index.html'))), + ) + } +}) diff --git a/web/src/api.ts b/web/src/api.ts index c1f21c3..5830366 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,5 @@ import { Capacitor, CapacitorHttp } from '@capacitor/core' -import type { Exam, Session, StreamEvent } from '@meteo-exam/shared' +import type { ClassInfo, DayWeather, Exam, FlashInfo, Session, StreamEvent, WeatherKind } from '@meteo-exam/shared' import { getBaseUrl, getToken } from './config' export class ApiFailure extends Error { @@ -13,7 +13,7 @@ export class ApiFailure extends Error { } interface RequestOptions { - method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: unknown /** Jeton explicite, sinon celui du stockage local. */ token?: string | null @@ -106,6 +106,23 @@ export const api = { request(`/api/exams/${id}`, { method: 'PATCH', body: input }), deleteExam: (id: number) => request(`/api/exams/${id}`, { method: 'DELETE' }), + + // --- Config admin --- + listDayWeather: () => request('/api/config/day-weather'), + + setDayWeather: (dayKey: string, kind: WeatherKind) => + request('/api/config/day-weather', { method: 'PUT', body: { dayKey, kind } }), + + clearDayWeather: (dayKey: string) => + request(`/api/config/day-weather/${encodeURIComponent(dayKey)}`, { method: 'DELETE' }), + + getFlash: () => request('/api/config/flash'), + + setFlash: (message: string) => + request('/api/config/flash', { method: 'PUT', body: { message } }), + + updateClass: (name: string, level: string | null) => + request('/api/config/class', { method: 'PATCH', body: { name, level } }), } /** diff --git a/web/src/store.ts b/web/src/store.ts index a3a7acb..6e8f92a 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -1,4 +1,4 @@ -import type { Exam, Session } from '@meteo-exam/shared' +import type { ClassInfo, Exam, FlashInfo, Session } from '@meteo-exam/shared' import { dominantKind, type WeatherKind } from '@meteo-exam/shared' import { api, openStream } from './api' import { clearToken, getToken, setToken } from './config' @@ -11,13 +11,17 @@ type Listener = () => void interface State { session: Session | null exams: Exam[] - /** Jours affichés par la frise, indépendamment de la fenêtre chargée. */ stripDays: Date[] selectedDay: string | null loading: boolean error: string | null - /** Passé à true quand le jeton est refusé : l'app revient à l'écran d'accueil. */ signedOut: boolean + /** Overrides météo posés par l'admin, indexés par jour local AAAA-MM-JJ. */ + dayWeather: Map + /** Flash info du jour (peut être null). */ + flash: FlashInfo | null + /** Infos classe en direct (nom, niveau). */ + klass: ClassInfo | null } const state: State = { @@ -28,6 +32,9 @@ const state: State = { loading: false, error: null, signedOut: false, + dayWeather: new Map(), + flash: null, + klass: null, } const listeners = new Set() @@ -47,7 +54,6 @@ export const getState = (): Readonly => state const windowBounds = (): { from: string; to: string } => { const from = new Date() from.setHours(0, 0, 0, 0) - // On charge large : la frise fait 7 jours mais les cartes listent la fenêtre. const to = new Date(from.getTime() + WINDOW_DAYS * 86_400_000) return { from: from.toISOString(), to: to.toISOString() } } @@ -65,17 +71,48 @@ export async function loadExams(): Promise { emit() } +export async function loadConfig(): Promise { + try { + const [dayWeather, flash] = await Promise.all([api.listDayWeather(), api.getFlash()]) + state.dayWeather = new Map(dayWeather.map((dw) => [dw.dayKey, dw.kind])) + state.flash = flash + } catch { + // config secondaire : on ignore silencieusement + } + emit() +} + function connectStream(): void { closeStream?.() closeStream = openStream((event) => { - if (event.type === 'ping') return - if (event.type === 'exam.deleted') { - const idx = state.exams.findIndex((e) => e.id === event.examId) - if (idx >= 0) state.exams.splice(idx, 1) - } else { - const index = state.exams.findIndex((e) => e.id === event.exam.id) - if (index >= 0) state.exams[index] = event.exam - else state.exams.push(event.exam) + switch (event.type) { + case 'ping': + return + case 'exam.deleted': { + const idx = state.exams.findIndex((e) => e.id === event.examId) + if (idx >= 0) state.exams.splice(idx, 1) + break + } + case 'exam.created': + case 'exam.updated': { + const index = state.exams.findIndex((e) => e.id === event.exam.id) + if (index >= 0) state.exams[index] = event.exam + else state.exams.push(event.exam) + break + } + case 'day_weather.updated': + state.dayWeather.set(event.dayWeather.dayKey, event.dayWeather.kind) + break + case 'day_weather.deleted': + state.dayWeather.delete(event.dayKey) + break + case 'flash.updated': + state.flash = event.flash + break + case 'class.updated': + state.klass = event.klass + if (state.session) state.session = { ...state.session, klass: event.klass } + break } state.exams.sort((a, b) => a.startsAt.localeCompare(b.startsAt)) emit() @@ -93,13 +130,17 @@ export async function bootstrap(): Promise { emit() try { state.session = await api.me(token) + state.klass = state.session.klass state.signedOut = false - await loadExams() + await Promise.all([loadExams(), loadConfig()]) connectStream() } catch { clearToken() state.session = null state.exams = [] + state.dayWeather.clear() + state.flash = null + state.klass = null state.signedOut = true } finally { state.loading = false @@ -110,10 +151,11 @@ export async function bootstrap(): Promise { export async function loginAs(token: string, session: Session): Promise { setToken(token) state.session = session + state.klass = session.klass state.signedOut = false state.loading = true emit() - await loadExams() + await Promise.all([loadExams(), loadConfig()]) connectStream() state.loading = false emit() @@ -128,6 +170,9 @@ export function signOut(): void { state.selectedDay = null state.signedOut = false state.error = null + state.dayWeather.clear() + state.flash = null + state.klass = null emit() } @@ -156,8 +201,14 @@ export function examsByDay(): Map { return grouped } +/** Statut d'un jour : override admin > statut dominant des évaluations. */ export function dayStatus(key: string): WeatherKind { - return dominantKind(examsByDay().get(key) ?? []) + return state.dayWeather.get(key) ?? dominantKind(examsByDay().get(key) ?? []) +} + +/** true si l'admin a forcé la météo de ce jour. */ +export function isDayOverridden(key: string): boolean { + return state.dayWeather.has(key) } export function todayExams(): Exam[] { @@ -168,6 +219,14 @@ export function findExam(id: number): Exam | undefined { return state.exams.find((e) => e.id === id) } +export function currentFlash(): FlashInfo | null { + return state.flash +} + +export function currentKlass(): ClassInfo | null { + return state.klass ?? state.session?.klass ?? null +} + // --- Écriture (admin) ------------------------------------------------------- export async function saveExam(input: unknown, id?: number): Promise { @@ -186,3 +245,28 @@ export async function removeExam(id: number): Promise { state.exams = state.exams.filter((e) => e.id !== id) emit() } + +export async function setDayWeather(dayKey: string, kind: WeatherKind): Promise { + await api.setDayWeather(dayKey, kind) + state.dayWeather.set(dayKey, kind) + emit() +} + +export async function clearDayWeather(dayKey: string): Promise { + await api.clearDayWeather(dayKey) + state.dayWeather.delete(dayKey) + emit() +} + +export async function setFlash(message: string): Promise { + const flash = await api.setFlash(message) + state.flash = flash + emit() +} + +export async function updateClass(name: string, level: string | null): Promise { + const klass = await api.updateClass(name, level) + state.klass = klass + if (state.session) state.session = { ...state.session, klass } + emit() +} diff --git a/web/src/views/plus.ts b/web/src/views/plus.ts index d14cecf..ab67957 100644 --- a/web/src/views/plus.ts +++ b/web/src/views/plus.ts @@ -1,16 +1,40 @@ import { h, mount } from '../dom' import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config' -import { signOut } from '../store' +import { + currentFlash, + currentKlass, + isAdmin, + setFlash, + signOut, + updateClass, +} from '../store' import { showToast } from '../toast' +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' + export function renderPlus(root: HTMLElement, rerender: () => void): void { + const klass = currentKlass() + const flash = currentFlash() + const admin = isAdmin() + const serverInput = h('input', { - class: 'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-sky-500 transition-colors', + 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.' }, @@ -19,69 +43,140 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { { icon: '🚨', title: 'Alerte Rouge / Tempête', accent: 'text-red-500', desc: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.' }, ] - mount( - root, + const children: (Node | null)[] = [] + + // --- Panneau admin --- + if (admin) { + children.push( + h( + 'div', + { class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4' }, + h('div', { class: 'flex items-center gap-2' }, + h('i', { class: 'fa-solid fa-user-shield text-sky-400' }), + h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider' }, 'Configuration admin'), + ), + + // Flash info + h('div', {}, + h('span', { class: labelClass }, 'Flash info du jour'), + flashInput, + h('p', { class: 'text-[10px] text-slate-600 font-semibold mt-1' }, + 'Message visible par toute la classe sur l’écran Météo.'), + h( + 'button', + { + class: 'mt-2 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', + onclick: async () => { + try { + await setFlash(flashInput.value.trim()) + showToast('Flash info mis à jour', 'success') + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, + 'Publier le flash info', + ), + ), + + // Édition classe + h('div', { class: 'pt-3 border-t border-slate-700/60' }, + h('span', { class: labelClass }, 'Nom de la classe'), + h('div', { class: 'grid grid-cols-2 gap-3' }, nameInput, levelInput), + h( + 'button', + { + class: 'mt-2 w-full bg-indigo-500 text-white text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', + onclick: async () => { + try { + await updateClass(nameInput.value.trim(), levelInput.value.trim() || null) + showToast('Classe mise à jour', 'success') + rerender() + } catch (error) { + showToast(error instanceof Error ? error.message : 'Erreur', 'reject') + } + }, + }, + 'Enregistrer la classe', + ), + ), + ), + ) + } + + // --- Serveur --- + children.push( h( 'div', - { class: 'space-y-5' }, + { 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( - '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. Sur l’émulateur Android : 10.0.2.2:8787'), - h( - 'button', - { - class: 'mt-3 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', - onclick: () => { - setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL) - showToast('Adresse du serveur enregistrée', 'success') - rerender() - }, + 'button', + { + class: 'mt-3 w-full bg-sky-500 text-slate-950 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', + onclick: () => { + setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL) + showToast('Adresse du serveur enregistrée', 'success') + rerender() }, - 'Enregistrer l’adresse', - ), + }, + '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( - '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() - }, + 'button', + { + class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform', + onclick: () => { + signOut() + rerender() }, - h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), - 'Se déconnecter', - ), + }, + h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }), + 'Se déconnecter', ), - 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), - ), + ), + ) + + // --- 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), ), ), ), ), - h( - 'p', - { class: 'text-[10px] text-slate-600 font-semibold text-center' }, - 'Météo Exam — v0.1.0 · Radar de rumeurs et métriques en phase 2', - ), ), ) + + children.push( + h( + 'p', + { class: 'text-[10px] text-slate-600 font-semibold text-center' }, + 'Météo Exam — v0.2.0 · Radar de rumeurs en phase 2', + ), + ) + + mount(root, ...children.filter((c): c is Node => c !== null)) } diff --git a/web/src/views/week.ts b/web/src/views/week.ts index 3bd8221..936d297 100644 --- a/web/src/views/week.ts +++ b/web/src/views/week.ts @@ -13,12 +13,16 @@ import { } from '../dates' import { examCard, examDetail, weatherBadge } from './cards' import { + clearDayWeather, + currentFlash, dayStatus, examsByDay, getState, isAdmin, + isDayOverridden, removeExam, selectDay, + setDayWeather, setError, } from '../store' import { showToast } from '../toast' @@ -105,7 +109,7 @@ function todayHero(onOpen: (exam: Exam) => void): HTMLElement { } /** Frise horizontale des 7 prochains jours. */ -function weekStrip(): HTMLElement { +function weekStrip(onAdminDay: (dayKey: string) => void): HTMLElement { const { stripDays, selectedDay } = getState() const today = new Date() @@ -118,6 +122,7 @@ function weekStrip(): HTMLElement { const count = examsByDay().get(key)?.length ?? 0 const isToday = isSameDay(date, today) const active = selectedDay === key + const overridden = isDayOverridden(key) return h( 'button', @@ -127,7 +132,10 @@ function weekStrip(): HTMLElement { ? 'bg-slate-700/60 border-sky-400/60' : 'bg-slate-800/70 border-slate-700/60' }`, - onclick: () => selectDay(key), + 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)), @@ -135,11 +143,118 @@ function weekStrip(): HTMLElement { 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() @@ -261,16 +376,19 @@ function openDetail(exam: Exam, rerender: () => void): void { export function renderWeek(root: HTMLElement, rerender: () => void): void { const { error, session } = getState() const open = (exam: Exam): void => openDetail(exam, rerender) + const openAdminDay = (dk: string): void => openDayWeatherModal(dk, rerender) const children: (Node | null)[] = [ + flashBanner(), todayHero(open), h( 'div', { 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'), - h('span', { class: 'text-xs font-bold text-sky-400' }, '7 prochains jours'), + h('span', { class: 'text-xs font-bold text-sky-400' }, + isAdmin() ? 'Tapez un jour pour forcer la météo' : '7 prochains jours'), ), - weekStrip(), + weekStrip(openAdminDay), ] if (error) {