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 0000000..d56f89e Binary files /dev/null and b/web/public/icon-192.png differ diff --git a/web/public/icon-512.png b/web/public/icon-512.png new file mode 100644 index 0000000..ca67bc5 Binary files /dev/null and b/web/public/icon-512.png differ 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) {