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:
Mathis 2026-10-04 17:05:56 +02:00
parent 6d154ed5fb
commit 246f989bdc
16 changed files with 782 additions and 86 deletions

View file

@ -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))
}

View file

@ -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) {