v0.2.0 — config admin (météo forcée, flash info, édition classe) + PWA
- 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)
This commit is contained in:
parent
6d154ed5fb
commit
246f989bdc
16 changed files with 782 additions and 86 deletions
108
scripts/gen-icons.mjs
Normal file
108
scripts/gen-icons.mjs
Normal file
|
|
@ -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)`)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ interface MemberRow {
|
||||||
role: Role
|
role: Role
|
||||||
class_name: string
|
class_name: string
|
||||||
class_level: string | null
|
class_level: string | null
|
||||||
|
class_flash_message: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newToken = (): string => randomBytes(32).toString('base64url')
|
export const newToken = (): string => randomBytes(32).toString('base64url')
|
||||||
|
|
@ -51,7 +52,7 @@ export function createClass(name: string, level: string | null, nickname: string
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
member: { id: memberId, classId, nickname, role: 'admin' },
|
member: { id: memberId, classId, nickname, role: 'admin' },
|
||||||
klass: { id: classId, name, level },
|
klass: { id: classId, name, level, flashMessage: null },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { code, ...create() }
|
return { code, ...create() }
|
||||||
|
|
@ -71,7 +72,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
member: { id: memberId, classId: row.id, nickname, role: 'student' },
|
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
|
const row = db
|
||||||
.prepare<[string], MemberRow>(
|
.prepare<[string], MemberRow>(
|
||||||
`SELECT m.id, m.class_id, m.nickname, m.role,
|
`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
|
FROM members m
|
||||||
JOIN classes c ON c.id = m.class_id
|
JOIN classes c ON c.id = m.class_id
|
||||||
WHERE m.token = ?`,
|
WHERE m.token = ?`,
|
||||||
|
|
@ -88,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
return {
|
return {
|
||||||
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role },
|
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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@ db.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS classes (
|
CREATE TABLE IF NOT EXISTS classes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
level TEXT,
|
level TEXT,
|
||||||
code TEXT NOT NULL UNIQUE,
|
code TEXT NOT NULL UNIQUE,
|
||||||
created_at TEXT NOT NULL
|
flash_message TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS members (
|
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 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()
|
export const nowIso = (): string => new Date().toISOString()
|
||||||
|
|
||||||
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */
|
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||||
import { classesRouter } from './routes/classes.js'
|
import { classesRouter } from './routes/classes.js'
|
||||||
import { examsRouter } from './routes/exams.js'
|
import { examsRouter } from './routes/exams.js'
|
||||||
import { streamRouter } from './routes/stream.js'
|
import { streamRouter } from './routes/stream.js'
|
||||||
|
import { configRouter } from './routes/config.js'
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
const PORT = Number(process.env['PORT'] ?? 8787)
|
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/classes', classesRouter)
|
||||||
app.use('/api/exams', examsRouter)
|
app.use('/api/exams', examsRouter)
|
||||||
app.use('/api/stream', streamRouter)
|
app.use('/api/stream', streamRouter)
|
||||||
|
app.use('/api/config', configRouter)
|
||||||
|
|
||||||
app.use('/api', (_req, res) => {
|
app.use('/api', (_req, res) => {
|
||||||
res.status(404).json({ error: 'Route inconnue' })
|
res.status(404).json({ error: 'Route inconnue' })
|
||||||
|
|
|
||||||
144
server/src/routes/config.ts
Normal file
144
server/src/routes/config.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
@ -83,8 +83,8 @@ const exams: SeedExam[] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const seed = db.transaction(() => {
|
const seed = db.transaction(() => {
|
||||||
db.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
|
db.prepare('INSERT INTO classes (name, level, code, flash_message, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(CLASS_NAME, LEVEL, CLASS_CODE, nowIso())
|
.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 = ?')
|
const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?')
|
||||||
.get(CLASS_CODE)!.id
|
.get(CLASS_CODE)!.id
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
|
|
@ -165,11 +167,31 @@ 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 {
|
||||||
|
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. */
|
/** 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 }
|
||||||
| { type: 'exam.deleted'; examId: number }
|
| { 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' }
|
| { type: 'ping' }
|
||||||
|
|
||||||
export const CLASS_CODE_LENGTH = 6
|
export const CLASS_CODE_LENGTH = 6
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,20 @@
|
||||||
<html lang="fr" class="h-full">
|
<html lang="fr" class="h-full">
|
||||||
<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" />
|
<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="#020617" />
|
||||||
<title>Météo Exam</title>
|
<title>Météo Exam</title>
|
||||||
|
|
||||||
|
<!-- PWA -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<meta name="apple-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-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
|
||||||
|
|
@ -19,5 +30,12 @@
|
||||||
<body class="bg-slate-950 text-slate-100 font-sans h-full">
|
<body class="bg-slate-950 text-slate-100 font-sans h-full">
|
||||||
<div id="app" class="flex items-center justify-center min-h-screen"></div>
|
<div id="app" class="flex items-center justify-center min-h-screen"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
BIN
web/public/icon-192.png
Normal file
BIN
web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
web/public/icon-512.png
Normal file
BIN
web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
31
web/public/manifest.webmanifest
Normal file
31
web/public/manifest.webmanifest
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
39
web/public/sw.js
Normal file
39
web/public/sw.js
Normal file
|
|
@ -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'))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Capacitor, CapacitorHttp } from '@capacitor/core'
|
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'
|
import { getBaseUrl, getToken } from './config'
|
||||||
|
|
||||||
export class ApiFailure extends Error {
|
export class ApiFailure extends Error {
|
||||||
|
|
@ -13,7 +13,7 @@ export class ApiFailure extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
body?: unknown
|
body?: unknown
|
||||||
/** Jeton explicite, sinon celui du stockage local. */
|
/** Jeton explicite, sinon celui du stockage local. */
|
||||||
token?: string | null
|
token?: string | null
|
||||||
|
|
@ -106,6 +106,23 @@ export const api = {
|
||||||
request<Exam>(`/api/exams/${id}`, { method: 'PATCH', body: input }),
|
request<Exam>(`/api/exams/${id}`, { method: 'PATCH', body: input }),
|
||||||
|
|
||||||
deleteExam: (id: number) => request<void>(`/api/exams/${id}`, { method: 'DELETE' }),
|
deleteExam: (id: number) => request<void>(`/api/exams/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// --- Config admin ---
|
||||||
|
listDayWeather: () => request<DayWeather[]>('/api/config/day-weather'),
|
||||||
|
|
||||||
|
setDayWeather: (dayKey: string, kind: WeatherKind) =>
|
||||||
|
request<DayWeather>('/api/config/day-weather', { method: 'PUT', body: { dayKey, kind } }),
|
||||||
|
|
||||||
|
clearDayWeather: (dayKey: string) =>
|
||||||
|
request<void>(`/api/config/day-weather/${encodeURIComponent(dayKey)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getFlash: () => request<FlashInfo>('/api/config/flash'),
|
||||||
|
|
||||||
|
setFlash: (message: string) =>
|
||||||
|
request<FlashInfo>('/api/config/flash', { method: 'PUT', body: { message } }),
|
||||||
|
|
||||||
|
updateClass: (name: string, level: string | null) =>
|
||||||
|
request<ClassInfo>('/api/config/class', { method: 'PATCH', body: { name, level } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
114
web/src/store.ts
114
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 { dominantKind, type WeatherKind } from '@meteo-exam/shared'
|
||||||
import { api, openStream } from './api'
|
import { api, openStream } from './api'
|
||||||
import { clearToken, getToken, setToken } from './config'
|
import { clearToken, getToken, setToken } from './config'
|
||||||
|
|
@ -11,13 +11,17 @@ type Listener = () => void
|
||||||
interface State {
|
interface State {
|
||||||
session: Session | null
|
session: Session | null
|
||||||
exams: Exam[]
|
exams: Exam[]
|
||||||
/** Jours affichés par la frise, indépendamment de la fenêtre chargée. */
|
|
||||||
stripDays: Date[]
|
stripDays: Date[]
|
||||||
selectedDay: string | null
|
selectedDay: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
/** Passé à true quand le jeton est refusé : l'app revient à l'écran d'accueil. */
|
|
||||||
signedOut: boolean
|
signedOut: boolean
|
||||||
|
/** Overrides météo posés par l'admin, indexés par jour local AAAA-MM-JJ. */
|
||||||
|
dayWeather: Map<string, WeatherKind>
|
||||||
|
/** Flash info du jour (peut être null). */
|
||||||
|
flash: FlashInfo | null
|
||||||
|
/** Infos classe en direct (nom, niveau). */
|
||||||
|
klass: ClassInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: State = {
|
const state: State = {
|
||||||
|
|
@ -28,6 +32,9 @@ const state: State = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
signedOut: false,
|
signedOut: false,
|
||||||
|
dayWeather: new Map(),
|
||||||
|
flash: null,
|
||||||
|
klass: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const listeners = new Set<Listener>()
|
const listeners = new Set<Listener>()
|
||||||
|
|
@ -47,7 +54,6 @@ export const getState = (): Readonly<State> => state
|
||||||
const windowBounds = (): { from: string; to: string } => {
|
const windowBounds = (): { from: string; to: string } => {
|
||||||
const from = new Date()
|
const from = new Date()
|
||||||
from.setHours(0, 0, 0, 0)
|
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)
|
const to = new Date(from.getTime() + WINDOW_DAYS * 86_400_000)
|
||||||
return { from: from.toISOString(), to: to.toISOString() }
|
return { from: from.toISOString(), to: to.toISOString() }
|
||||||
}
|
}
|
||||||
|
|
@ -65,17 +71,48 @@ export async function loadExams(): Promise<void> {
|
||||||
emit()
|
emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(): Promise<void> {
|
||||||
|
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 {
|
function connectStream(): void {
|
||||||
closeStream?.()
|
closeStream?.()
|
||||||
closeStream = openStream((event) => {
|
closeStream = openStream((event) => {
|
||||||
if (event.type === 'ping') return
|
switch (event.type) {
|
||||||
if (event.type === 'exam.deleted') {
|
case 'ping':
|
||||||
const idx = state.exams.findIndex((e) => e.id === event.examId)
|
return
|
||||||
if (idx >= 0) state.exams.splice(idx, 1)
|
case 'exam.deleted': {
|
||||||
} else {
|
const idx = state.exams.findIndex((e) => e.id === event.examId)
|
||||||
const index = state.exams.findIndex((e) => e.id === event.exam.id)
|
if (idx >= 0) state.exams.splice(idx, 1)
|
||||||
if (index >= 0) state.exams[index] = event.exam
|
break
|
||||||
else state.exams.push(event.exam)
|
}
|
||||||
|
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))
|
state.exams.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
|
||||||
emit()
|
emit()
|
||||||
|
|
@ -93,13 +130,17 @@ export async function bootstrap(): Promise<void> {
|
||||||
emit()
|
emit()
|
||||||
try {
|
try {
|
||||||
state.session = await api.me(token)
|
state.session = await api.me(token)
|
||||||
|
state.klass = state.session.klass
|
||||||
state.signedOut = false
|
state.signedOut = false
|
||||||
await loadExams()
|
await Promise.all([loadExams(), loadConfig()])
|
||||||
connectStream()
|
connectStream()
|
||||||
} catch {
|
} catch {
|
||||||
clearToken()
|
clearToken()
|
||||||
state.session = null
|
state.session = null
|
||||||
state.exams = []
|
state.exams = []
|
||||||
|
state.dayWeather.clear()
|
||||||
|
state.flash = null
|
||||||
|
state.klass = null
|
||||||
state.signedOut = true
|
state.signedOut = true
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
|
@ -110,10 +151,11 @@ export async function bootstrap(): Promise<void> {
|
||||||
export async function loginAs(token: string, session: Session): Promise<void> {
|
export async function loginAs(token: string, session: Session): Promise<void> {
|
||||||
setToken(token)
|
setToken(token)
|
||||||
state.session = session
|
state.session = session
|
||||||
|
state.klass = session.klass
|
||||||
state.signedOut = false
|
state.signedOut = false
|
||||||
state.loading = true
|
state.loading = true
|
||||||
emit()
|
emit()
|
||||||
await loadExams()
|
await Promise.all([loadExams(), loadConfig()])
|
||||||
connectStream()
|
connectStream()
|
||||||
state.loading = false
|
state.loading = false
|
||||||
emit()
|
emit()
|
||||||
|
|
@ -128,6 +170,9 @@ export function signOut(): void {
|
||||||
state.selectedDay = null
|
state.selectedDay = null
|
||||||
state.signedOut = false
|
state.signedOut = false
|
||||||
state.error = null
|
state.error = null
|
||||||
|
state.dayWeather.clear()
|
||||||
|
state.flash = null
|
||||||
|
state.klass = null
|
||||||
emit()
|
emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,8 +201,14 @@ export function examsByDay(): Map<string, Exam[]> {
|
||||||
return grouped
|
return grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Statut d'un jour : override admin > statut dominant des évaluations. */
|
||||||
export function dayStatus(key: string): WeatherKind {
|
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[] {
|
export function todayExams(): Exam[] {
|
||||||
|
|
@ -168,6 +219,14 @@ export function findExam(id: number): Exam | undefined {
|
||||||
return state.exams.find((e) => e.id === id)
|
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) -------------------------------------------------------
|
// --- Écriture (admin) -------------------------------------------------------
|
||||||
|
|
||||||
export async function saveExam(input: unknown, id?: number): Promise<Exam> {
|
export async function saveExam(input: unknown, id?: number): Promise<Exam> {
|
||||||
|
|
@ -186,3 +245,28 @@ export async function removeExam(id: number): Promise<void> {
|
||||||
state.exams = state.exams.filter((e) => e.id !== id)
|
state.exams = state.exams.filter((e) => e.id !== id)
|
||||||
emit()
|
emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setDayWeather(dayKey: string, kind: WeatherKind): Promise<void> {
|
||||||
|
await api.setDayWeather(dayKey, kind)
|
||||||
|
state.dayWeather.set(dayKey, kind)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDayWeather(dayKey: string): Promise<void> {
|
||||||
|
await api.clearDayWeather(dayKey)
|
||||||
|
state.dayWeather.delete(dayKey)
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFlash(message: string): Promise<void> {
|
||||||
|
const flash = await api.setFlash(message)
|
||||||
|
state.flash = flash
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClass(name: string, level: string | null): Promise<void> {
|
||||||
|
const klass = await api.updateClass(name, level)
|
||||||
|
state.klass = klass
|
||||||
|
if (state.session) state.session = { ...state.session, klass }
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,40 @@
|
||||||
import { h, mount } from '../dom'
|
import { h, mount } from '../dom'
|
||||||
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
|
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'
|
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 {
|
export function renderPlus(root: HTMLElement, rerender: () => void): void {
|
||||||
|
const klass = currentKlass()
|
||||||
|
const flash = currentFlash()
|
||||||
|
const admin = isAdmin()
|
||||||
|
|
||||||
const serverInput = h('input', {
|
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(),
|
value: getBaseUrl(),
|
||||||
autocomplete: 'off',
|
autocomplete: 'off',
|
||||||
inputMode: 'url',
|
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 }[] = [
|
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: '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: '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.' },
|
{ icon: '🚨', title: 'Alerte Rouge / Tempête', accent: 'text-red-500', desc: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.' },
|
||||||
]
|
]
|
||||||
|
|
||||||
mount(
|
const children: (Node | null)[] = []
|
||||||
root,
|
|
||||||
|
// --- 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(
|
h(
|
||||||
'div',
|
'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(
|
h(
|
||||||
'div',
|
'button',
|
||||||
{ 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'),
|
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',
|
||||||
serverInput,
|
onclick: () => {
|
||||||
h('p', { class: 'text-[10px] text-slate-500 font-semibold mt-2' },
|
setBaseUrl(serverInput.value.trim() || DEFAULT_BASE_URL)
|
||||||
'Adresse de l’API. Sur l’émulateur Android : 10.0.2.2:8787'),
|
showToast('Adresse du serveur enregistrée', 'success')
|
||||||
h(
|
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(
|
h(
|
||||||
'div',
|
'button',
|
||||||
{ 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'),
|
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',
|
||||||
h(
|
onclick: () => {
|
||||||
'button',
|
signOut()
|
||||||
{
|
rerender()
|
||||||
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) =>
|
// --- Légende ---
|
||||||
h(
|
children.push(
|
||||||
'div',
|
h('div', {},
|
||||||
{ class: 'bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3' },
|
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-2' }, 'Légende météorologique'),
|
||||||
h('span', { class: 'text-3xl' }, item.icon),
|
h('div', { class: 'grid grid-cols-1 gap-2.5' },
|
||||||
h('div', {},
|
...legendItems.map((item) =>
|
||||||
h('h4', { class: `text-sm font-black ${item.accent}` }, item.title),
|
h(
|
||||||
h('p', { class: 'text-xs text-slate-300 font-medium' }, item.desc),
|
'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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,16 @@ import {
|
||||||
} from '../dates'
|
} from '../dates'
|
||||||
import { examCard, examDetail, weatherBadge } from './cards'
|
import { examCard, examDetail, weatherBadge } from './cards'
|
||||||
import {
|
import {
|
||||||
|
clearDayWeather,
|
||||||
|
currentFlash,
|
||||||
dayStatus,
|
dayStatus,
|
||||||
examsByDay,
|
examsByDay,
|
||||||
getState,
|
getState,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isDayOverridden,
|
||||||
removeExam,
|
removeExam,
|
||||||
selectDay,
|
selectDay,
|
||||||
|
setDayWeather,
|
||||||
setError,
|
setError,
|
||||||
} from '../store'
|
} from '../store'
|
||||||
import { showToast } from '../toast'
|
import { showToast } from '../toast'
|
||||||
|
|
@ -105,7 +109,7 @@ function todayHero(onOpen: (exam: Exam) => void): HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Frise horizontale des 7 prochains jours. */
|
/** Frise horizontale des 7 prochains jours. */
|
||||||
function weekStrip(): HTMLElement {
|
function weekStrip(onAdminDay: (dayKey: string) => void): HTMLElement {
|
||||||
const { stripDays, selectedDay } = getState()
|
const { stripDays, selectedDay } = getState()
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|
||||||
|
|
@ -118,6 +122,7 @@ function weekStrip(): HTMLElement {
|
||||||
const count = examsByDay().get(key)?.length ?? 0
|
const count = examsByDay().get(key)?.length ?? 0
|
||||||
const isToday = isSameDay(date, today)
|
const isToday = isSameDay(date, today)
|
||||||
const active = selectedDay === key
|
const active = selectedDay === key
|
||||||
|
const overridden = isDayOverridden(key)
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'button',
|
'button',
|
||||||
|
|
@ -127,7 +132,10 @@ function weekStrip(): HTMLElement {
|
||||||
? 'bg-slate-700/60 border-sky-400/60'
|
? 'bg-slate-700/60 border-sky-400/60'
|
||||||
: 'bg-slate-800/70 border-slate-700/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'}` },
|
h('span', { class: `text-[9px] font-black tracking-wider ${isToday ? 'text-sky-400' : 'text-slate-500'}` },
|
||||||
isToday ? "AUJ." : dayNameShort(date)),
|
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-base leading-none' }, WEATHER[status].icon),
|
||||||
h('span', { class: 'text-[9px] font-bold text-slate-500' },
|
h('span', { class: 'text-[9px] font-bold text-slate-500' },
|
||||||
count === 0 ? '—' : `${count} éval.`),
|
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. */
|
/** Liste groupée par jour, filtrée ou non par la frise. */
|
||||||
function dayList(onOpen: (exam: Exam) => void): HTMLElement {
|
function dayList(onOpen: (exam: Exam) => void): HTMLElement {
|
||||||
const { selectedDay, stripDays } = getState()
|
const { selectedDay, stripDays } = getState()
|
||||||
|
|
@ -261,16 +376,19 @@ function openDetail(exam: Exam, rerender: () => void): void {
|
||||||
export function renderWeek(root: HTMLElement, rerender: () => void): void {
|
export function renderWeek(root: HTMLElement, rerender: () => void): void {
|
||||||
const { error, session } = getState()
|
const { error, session } = getState()
|
||||||
const open = (exam: Exam): void => openDetail(exam, rerender)
|
const open = (exam: Exam): void => openDetail(exam, rerender)
|
||||||
|
const openAdminDay = (dk: string): void => openDayWeatherModal(dk, rerender)
|
||||||
|
|
||||||
const children: (Node | null)[] = [
|
const children: (Node | null)[] = [
|
||||||
|
flashBanner(),
|
||||||
todayHero(open),
|
todayHero(open),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'flex justify-between items-center pt-2' },
|
{ 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('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) {
|
if (error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue