Météo Exam v0.1.0 — calendrier des évaluations en bulletin météo
- API Express + SQLite : classes, évaluations, SSE, auth Bearer - Front Vite + TS + Tailwind : frise 7 jours, Exam Cards, modales - Capacitor Android : APK debug, CapacitorHttp natif - Seed : classe Terminale S2 (code TERMS2)
This commit is contained in:
commit
6d154ed5fb
92 changed files with 9574 additions and 0 deletions
135
web/src/api.ts
Normal file
135
web/src/api.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { Capacitor, CapacitorHttp } from '@capacitor/core'
|
||||
import type { Exam, Session, StreamEvent } from '@meteo-exam/shared'
|
||||
import { getBaseUrl, getToken } from './config'
|
||||
|
||||
export class ApiFailure extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiFailure'
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
/** Jeton explicite, sinon celui du stockage local. */
|
||||
token?: string | null
|
||||
}
|
||||
|
||||
interface HttpResult {
|
||||
status: number
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Sur Android (Capacitor natif), `CapacitorHttp` passe par OkHttp : le WebView
|
||||
* n'est pas impliqué, pas de mixed content ni de blocage réseau embarqué.
|
||||
* Dans un navigateur (dev), on retombe sur `fetch`.
|
||||
*/
|
||||
async function http(path: string, options: RequestOptions): Promise<HttpResult> {
|
||||
const { method = 'GET', body, token = getToken() } = options
|
||||
const url = `${getBaseUrl()}${path}`
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const response = await CapacitorHttp.request({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
data: body as Record<string, unknown> | undefined,
|
||||
})
|
||||
return { status: response.status, data: response.data }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new ApiFailure(`Serveur injoignable. Vérifiez l’adresse dans les réglages. (${message})`, 0)
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiFailure('Serveur injoignable. Vérifiez l’adresse dans les réglages.', 0)
|
||||
}
|
||||
|
||||
if (response.status === 204) return { status: 204, data: null }
|
||||
|
||||
const text = await response.text()
|
||||
return { status: response.status, data: text ? JSON.parse(text) : null }
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { status, data } = await http(path, options)
|
||||
|
||||
if (status === 204) return undefined as T
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
const message =
|
||||
data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
|
||||
? data.error
|
||||
: `Erreur ${status}`
|
||||
throw new ApiFailure(message, status)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request<{ ok: boolean }>('/api/health'),
|
||||
|
||||
createClass: (input: { name: string; level?: string | null; nickname: string }) =>
|
||||
request<Session & { token: string; code: string }>('/api/classes', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
}),
|
||||
|
||||
joinClass: (input: { code: string; nickname: string }) =>
|
||||
request<Session & { token: string }>('/api/classes/join', { method: 'POST', body: input }),
|
||||
|
||||
me: (token?: string | null) => request<Session>('/api/classes/me', { token }),
|
||||
|
||||
listExams: (from: string, to: string) =>
|
||||
request<Exam[]>(`/api/exams?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
||||
|
||||
createExam: (input: unknown) => request<Exam>('/api/exams', { method: 'POST', body: input }),
|
||||
|
||||
updateExam: (id: number, input: unknown) =>
|
||||
request<Exam>(`/api/exams/${id}`, { method: 'PATCH', body: input }),
|
||||
|
||||
deleteExam: (id: number) => request<void>(`/api/exams/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux SSE du calendrier. `EventSource` ne permettant pas de poser
|
||||
* d'en-têtes, le jeton passe en query string (le serveur l'accepte).
|
||||
*/
|
||||
export function openStream(onEvent: (event: StreamEvent) => void): () => void {
|
||||
const token = getToken()
|
||||
if (!token) return () => {}
|
||||
|
||||
const source = new EventSource(`${getBaseUrl()}/api/stream?token=${encodeURIComponent(token)}`)
|
||||
|
||||
const handle = (event: MessageEvent<string>): void => {
|
||||
try {
|
||||
onEvent(JSON.parse(event.data) as StreamEvent)
|
||||
} catch {
|
||||
// trame illisible : on l'ignore plutôt que de casser le flux
|
||||
}
|
||||
}
|
||||
|
||||
source.onmessage = handle
|
||||
source.onerror = () => {
|
||||
// EventSource reconnecte tout seul ; on ne ferme pas ici.
|
||||
}
|
||||
|
||||
return () => source.close()
|
||||
}
|
||||
17
web/src/config.ts
Normal file
17
web/src/config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const TOKEN_KEY = 'meteo-exam.token'
|
||||
const BASE_KEY = 'meteo-exam.baseUrl'
|
||||
|
||||
/** `10.0.2.2` = l'hôte de la machine depuis l'émulateur Android. */
|
||||
export const DEFAULT_BASE_URL = 'http://10.0.2.2:8787'
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string): void => localStorage.setItem(TOKEN_KEY, token)
|
||||
export const clearToken = (): void => localStorage.removeItem(TOKEN_KEY)
|
||||
|
||||
export const getBaseUrl = (): string => localStorage.getItem(BASE_KEY) ?? DEFAULT_BASE_URL
|
||||
|
||||
export function setBaseUrl(url: string): string {
|
||||
const clean = url.trim().replace(/\/+$/, '')
|
||||
localStorage.setItem(BASE_KEY, clean)
|
||||
return clean
|
||||
}
|
||||
63
web/src/dates.ts
Normal file
63
web/src/dates.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export const DAY_MS = 86_400_000
|
||||
|
||||
const DAYS = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'] as const
|
||||
const MONTHS = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
] as const
|
||||
|
||||
/** Clé de regroupement locale : `AAAA-MM-JJ`. Utile comme clé de Map. */
|
||||
export function dayKey(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function parseDayKey(key: string): Date {
|
||||
const [y, m, d] = key.split('-').map(Number)
|
||||
return new Date(y!, (m ?? 1) - 1, d ?? 1)
|
||||
}
|
||||
|
||||
export const startOfDay = (date: Date): Date =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
/** Les `count` jours qui suivent aujourd'hui, en incluant aujourd'hui. */
|
||||
export function upcomingDays(count: number): Date[] {
|
||||
const first = startOfDay(new Date())
|
||||
return Array.from({ length: count }, (_, i) => new Date(first.getTime() + i * DAY_MS))
|
||||
}
|
||||
|
||||
export const isSameDay = (a: Date, b: Date): boolean => dayKey(a) === dayKey(b)
|
||||
|
||||
export const dayName = (date: Date): string => DAYS[date.getDay()]!
|
||||
|
||||
export const dayNameShort = (date: Date): string => dayName(date).slice(0, 3).toUpperCase()
|
||||
|
||||
export const dayNumber = (date: Date): string => String(date.getDate())
|
||||
|
||||
export function longDate(date: Date): string {
|
||||
return `${dayName(date)} ${date.getDate()} ${MONTHS[date.getMonth()]}`
|
||||
}
|
||||
|
||||
export function shortDate(date: Date): string {
|
||||
return `${date.getDate()} ${MONTHS[date.getMonth()]!.slice(0, 4)}.`
|
||||
}
|
||||
|
||||
export const isoDayInput = (date: Date): string => dayKey(date)
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** « 1 h 30 », « 45 min ». */
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatWeight(weight: number): string {
|
||||
return Number.isInteger(weight) ? weight.toFixed(1) : weight.toString()
|
||||
}
|
||||
46
web/src/dom.ts
Normal file
46
web/src/dom.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export function h<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
props: Partial<HTMLElementTagNameMap[K]> & { class?: string; dataset?: Record<string, string> } = {},
|
||||
...children: (Node | string | null | undefined | false)[]
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag)
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (key === 'class') el.className = String(value)
|
||||
else if (key === 'dataset') Object.assign(el.dataset, value)
|
||||
else if (key === 'style') Object.assign(el.style, value)
|
||||
else if (key.startsWith('on') && typeof value === 'function') {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
|
||||
} else if (key === 'html') el.innerHTML = String(value)
|
||||
else if (key in el) (el as unknown as Record<string, unknown>)[key] = value
|
||||
else el.setAttribute(key, String(value))
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (child === null || child === undefined || child === false) continue
|
||||
el.append(typeof child === 'string' ? document.createTextNode(child) : child)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
export const clear = (el: HTMLElement): void => {
|
||||
el.replaceChildren()
|
||||
}
|
||||
|
||||
/** Remplace les enfants d'un conteneur en une seule passe. */
|
||||
export const mount = (el: HTMLElement, ...children: Node[]): void => {
|
||||
el.replaceChildren(...children)
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe une valeur destinée à `innerHTML` (libellés de 서버, notes libres).
|
||||
* Toutes les données de classe sont saisies par des élèves : rien n'est fiable.
|
||||
*/
|
||||
export const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
109
web/src/main.ts
Normal file
109
web/src/main.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import './styles.css'
|
||||
import { h, mount } from './dom'
|
||||
import { bootstrap, getState, isAdmin, subscribe } from './store'
|
||||
import { renderJoin } from './views/join'
|
||||
import { renderWeek } from './views/week'
|
||||
import { renderRadar } from './views/radar'
|
||||
import { renderMap } from './views/map'
|
||||
import { renderPlus } from './views/plus'
|
||||
import { getBaseUrl } from './config'
|
||||
|
||||
type Tab = 'meteo' | 'radar' | 'carte' | 'plus'
|
||||
|
||||
const app = document.getElementById('app')!
|
||||
let activeTab: Tab = 'meteo'
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: 'meteo', label: 'Météo', icon: 'fa-cloud-sun' },
|
||||
{ id: 'radar', label: 'Radar', icon: 'fa-satellite-dish' },
|
||||
{ id: 'carte', label: 'La Carte', icon: 'fa-map-marked-alt' },
|
||||
{ id: 'plus', label: 'Plus', icon: 'fa-ellipsis-h' },
|
||||
]
|
||||
|
||||
function renderApp(): void {
|
||||
const state = getState()
|
||||
|
||||
// Écran d'accueil : pas de session valide.
|
||||
if (!state.session) {
|
||||
const container = h('div', { class: 'mobile-container' })
|
||||
const main = h('main', { class: 'flex-1 overflow-y-auto no-scrollbar p-5' })
|
||||
renderJoin(main, renderApp)
|
||||
container.append(main)
|
||||
mount(app, container)
|
||||
return
|
||||
}
|
||||
|
||||
// App pleine : header + contenu + nav.
|
||||
const container = h('div', { class: 'mobile-container' })
|
||||
|
||||
const header = h(
|
||||
'header',
|
||||
{ class: 'pt-8 pb-3 px-6 bg-slate-900/80 backdrop-blur-md flex justify-between items-center z-20 border-b border-slate-800/60' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center gap-3' },
|
||||
h('div', { class: 'w-9 h-9 rounded-2xl bg-gradient-to-tr from-sky-400 to-indigo-500 flex items-center justify-center text-white font-black shadow-md text-sm' }, 'ME'),
|
||||
h('div', {},
|
||||
h('h1', { class: 'text-lg font-extrabold text-white tracking-tight leading-none' }, 'Météo Exam'),
|
||||
h('p', { class: 'text-xs font-bold text-sky-400 mt-0.5' }, `${state.session.klass.name}${state.session.klass.level ? ` · ${state.session.klass.level}` : ''}`),
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center gap-2' },
|
||||
isAdmin()
|
||||
? h('span', { class: 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-extrabold bg-sky-500/10 text-sky-400 border border-sky-500/20' },
|
||||
h('i', { class: 'fa-solid fa-user-shield mr-1' }), 'Admin')
|
||||
: null,
|
||||
h('span', { class: 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-extrabold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20' },
|
||||
h('span', { class: 'w-2 h-2 rounded-full bg-emerald-500 animate-ping mr-1.5' }),
|
||||
'Direct'),
|
||||
),
|
||||
)
|
||||
|
||||
const main = h('main', { class: 'flex-1 overflow-y-auto no-scrollbar p-5 pb-28 relative' })
|
||||
const rerender = (): void => renderApp()
|
||||
|
||||
switch (activeTab) {
|
||||
case 'meteo': renderWeek(main, rerender); break
|
||||
case 'radar': renderRadar(main, rerender); break
|
||||
case 'carte': renderMap(main, rerender); break
|
||||
case 'plus': renderPlus(main, rerender); break
|
||||
}
|
||||
|
||||
const nav = h(
|
||||
'nav',
|
||||
{ class: 'absolute bottom-0 left-0 right-0 bg-slate-900/95 backdrop-blur-xl border-t border-slate-800 px-4 py-3 flex justify-around items-center z-30' },
|
||||
...TABS.map((tab) => {
|
||||
const active = tab.id === activeTab
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: `flex flex-col items-center gap-1 transition-colors ${active ? 'text-sky-400' : 'text-slate-500'}`,
|
||||
onclick: () => {
|
||||
activeTab = tab.id
|
||||
renderApp()
|
||||
},
|
||||
},
|
||||
tab.id === 'radar'
|
||||
? h('div', { class: 'relative' },
|
||||
h('i', { class: `fa-solid ${tab.icon} text-xl` }),
|
||||
h('span', { class: 'absolute -top-1 -right-1 w-2 h-2 bg-rose-500 rounded-full' }))
|
||||
: h('i', { class: `fa-solid ${tab.icon} text-xl` }),
|
||||
h('span', { class: 'text-[10px] font-black tracking-wide' }, tab.label),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
container.append(header, main, nav)
|
||||
mount(app, container)
|
||||
}
|
||||
|
||||
// --- Bootstrapping -----------------------------------------------------------
|
||||
|
||||
subscribe(renderApp)
|
||||
|
||||
void bootstrap().then(() => {
|
||||
renderApp()
|
||||
console.log(`Météo Exam — serveur : ${getBaseUrl()}`)
|
||||
})
|
||||
188
web/src/store.ts
Normal file
188
web/src/store.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import type { Exam, Session } from '@meteo-exam/shared'
|
||||
import { dominantKind, type WeatherKind } from '@meteo-exam/shared'
|
||||
import { api, openStream } from './api'
|
||||
import { clearToken, getToken, setToken } from './config'
|
||||
import { dayKey, upcomingDays } from './dates'
|
||||
|
||||
const WINDOW_DAYS = 14
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
session: null,
|
||||
exams: [],
|
||||
stripDays: upcomingDays(7),
|
||||
selectedDay: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signedOut: false,
|
||||
}
|
||||
|
||||
const listeners = new Set<Listener>()
|
||||
let closeStream: (() => void) | null = null
|
||||
|
||||
export const subscribe = (listener: Listener): (() => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
const emit = (): void => {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export const getState = (): Readonly<State> => 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() }
|
||||
}
|
||||
|
||||
export async function loadExams(): Promise<void> {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
const { from, to } = windowBounds()
|
||||
try {
|
||||
state.exams = await api.listExams(from, to)
|
||||
state.error = null
|
||||
} catch (error) {
|
||||
state.error = error instanceof Error ? error.message : 'Chargement impossible'
|
||||
}
|
||||
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)
|
||||
}
|
||||
state.exams.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
|
||||
emit()
|
||||
})
|
||||
}
|
||||
|
||||
/** Restaure la session au démarrage à partir du jeton stocké. */
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
emit()
|
||||
return
|
||||
}
|
||||
state.loading = true
|
||||
emit()
|
||||
try {
|
||||
state.session = await api.me(token)
|
||||
state.signedOut = false
|
||||
await loadExams()
|
||||
connectStream()
|
||||
} catch {
|
||||
clearToken()
|
||||
state.session = null
|
||||
state.exams = []
|
||||
state.signedOut = true
|
||||
} finally {
|
||||
state.loading = false
|
||||
emit()
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginAs(token: string, session: Session): Promise<void> {
|
||||
setToken(token)
|
||||
state.session = session
|
||||
state.signedOut = false
|
||||
state.loading = true
|
||||
emit()
|
||||
await loadExams()
|
||||
connectStream()
|
||||
state.loading = false
|
||||
emit()
|
||||
}
|
||||
|
||||
export function signOut(): void {
|
||||
closeStream?.()
|
||||
closeStream = null
|
||||
clearToken()
|
||||
state.session = null
|
||||
state.exams = []
|
||||
state.selectedDay = null
|
||||
state.signedOut = false
|
||||
state.error = null
|
||||
emit()
|
||||
}
|
||||
|
||||
export function selectDay(key: string | null): void {
|
||||
state.selectedDay = state.selectedDay === key ? null : key
|
||||
emit()
|
||||
}
|
||||
|
||||
export function setError(message: string | null): void {
|
||||
state.error = message
|
||||
emit()
|
||||
}
|
||||
|
||||
// --- Sélecteurs -------------------------------------------------------------
|
||||
|
||||
export const isAdmin = (): boolean => state.session?.member.role === 'admin'
|
||||
|
||||
export function examsByDay(): Map<string, Exam[]> {
|
||||
const grouped = new Map<string, Exam[]>()
|
||||
for (const exam of state.exams) {
|
||||
const key = dayKey(new Date(exam.startsAt))
|
||||
const bucket = grouped.get(key)
|
||||
if (bucket) bucket.push(exam)
|
||||
else grouped.set(key, [exam])
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export function dayStatus(key: string): WeatherKind {
|
||||
return dominantKind(examsByDay().get(key) ?? [])
|
||||
}
|
||||
|
||||
export function todayExams(): Exam[] {
|
||||
return examsByDay().get(dayKey(new Date())) ?? []
|
||||
}
|
||||
|
||||
export function findExam(id: number): Exam | undefined {
|
||||
return state.exams.find((e) => e.id === id)
|
||||
}
|
||||
|
||||
// --- Écriture (admin) -------------------------------------------------------
|
||||
|
||||
export async function saveExam(input: unknown, id?: number): Promise<Exam> {
|
||||
const exam = id === undefined ? await api.createExam(input) : await api.updateExam(id, input)
|
||||
const index = state.exams.findIndex((e) => e.id === exam.id)
|
||||
if (index >= 0) state.exams[index] = exam
|
||||
else state.exams.push(exam)
|
||||
state.exams.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
|
||||
state.error = null
|
||||
emit()
|
||||
return exam
|
||||
}
|
||||
|
||||
export async function removeExam(id: number): Promise<void> {
|
||||
await api.deleteExam(id)
|
||||
state.exams = state.exams.filter((e) => e.id !== id)
|
||||
emit()
|
||||
}
|
||||
66
web/src/styles.css
Normal file
66
web/src/styles.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
/* Les classes Tailwind provenant de @meteo-exam/shared doivent être scannées
|
||||
par Tailwind : elles ne sont pas dans les sources du package web. */
|
||||
@source '../../shared/src';
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #020617;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: none;
|
||||
user-select: none;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
/* Hide scrollbars */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Mobile phone container (même forme que le prototype) */
|
||||
.mobile-container {
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
height: 100vh;
|
||||
max-height: 932px;
|
||||
margin: 0 auto;
|
||||
background-color: #0f172a;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 450px) {
|
||||
.mobile-container {
|
||||
height: 90vh;
|
||||
border-radius: 48px;
|
||||
margin-top: 5vh;
|
||||
border: 8px solid #1e293b;
|
||||
box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
.animate-pulse-glow {
|
||||
animation: pulseGlow 3s infinite ease-in-out;
|
||||
}
|
||||
39
web/src/toast.ts
Normal file
39
web/src/toast.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
type ToastKind = 'success' | 'reject' | 'info'
|
||||
|
||||
let container: HTMLElement | null = null
|
||||
let hideTimer: number | undefined
|
||||
|
||||
function ensureContainer(): HTMLElement {
|
||||
if (container && document.body.contains(container)) return container
|
||||
container = document.createElement('div')
|
||||
container.className =
|
||||
'fixed top-20 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 px-4 py-2 rounded-full text-xs font-bold shadow-2xl border border-slate-700 bg-slate-800 text-slate-100 transition-all duration-300 opacity-0 pointer-events-none'
|
||||
document.body.append(container)
|
||||
return container
|
||||
}
|
||||
|
||||
const ICONS: Record<ToastKind, string> = {
|
||||
success: 'fa-solid fa-circle-check text-emerald-400',
|
||||
reject: 'fa-solid fa-circle-xmark text-rose-400',
|
||||
info: 'fa-solid fa-circle-info text-sky-400',
|
||||
}
|
||||
|
||||
export function showToast(message: string, kind: ToastKind = 'success'): void {
|
||||
const el = ensureContainer()
|
||||
el.replaceChildren()
|
||||
|
||||
const icon = document.createElement('i')
|
||||
icon.className = ICONS[kind]
|
||||
const text = document.createElement('span')
|
||||
text.textContent = message
|
||||
el.append(icon, text)
|
||||
|
||||
el.classList.remove('opacity-0', '-translate-y-2', 'pointer-events-none')
|
||||
el.classList.add('opacity-100', 'translate-y-0')
|
||||
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = window.setTimeout(() => {
|
||||
el.classList.remove('opacity-100', 'translate-y-0')
|
||||
el.classList.add('opacity-0', '-translate-y-2', 'pointer-events-none')
|
||||
}, 2600)
|
||||
}
|
||||
103
web/src/views/cards.ts
Normal file
103
web/src/views/cards.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { WEATHER, type WeatherKind } from '@meteo-exam/shared'
|
||||
import { h } from '../dom'
|
||||
import { formatDuration, formatTime, formatWeight, longDate } from '../dates'
|
||||
import type { Exam } from '@meteo-exam/shared'
|
||||
|
||||
/** Carte compacte d'une évaluation dans la liste. */
|
||||
export function examCard(exam: Exam, onOpen: (exam: Exam) => void): HTMLElement {
|
||||
const meta = WEATHER[exam.kind]
|
||||
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class:
|
||||
'w-full text-left bg-slate-800/80 border border-slate-700/60 rounded-2xl p-4 flex items-center justify-between gap-3.5 shadow-sm active:scale-[0.99] transition-transform',
|
||||
onclick: () => onOpen(exam),
|
||||
},
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center gap-3.5 min-w-0' },
|
||||
h(
|
||||
'div',
|
||||
{ class: `w-12 h-12 shrink-0 rounded-2xl border flex items-center justify-center text-2xl ${meta.chip}` },
|
||||
meta.icon,
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'min-w-0' },
|
||||
h('p', { class: `text-xs font-extrabold uppercase tracking-wide truncate ${meta.accent}` },
|
||||
`${formatTime(exam.startsAt)} · ${meta.label}`),
|
||||
h('h4', { class: 'text-base font-bold text-slate-100 truncate' }, exam.subject),
|
||||
h(
|
||||
'p',
|
||||
{ class: 'text-xs text-slate-400 font-semibold truncate' },
|
||||
[exam.chapter, exam.room].filter(Boolean).join(' · ') || formatDuration(exam.durationMin),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'shrink-0 flex flex-col items-end gap-1.5' },
|
||||
h(
|
||||
'span',
|
||||
{ class: 'px-2.5 py-1 rounded-lg bg-slate-700/50 text-slate-300 text-xs font-bold' },
|
||||
exam.weight > 0 ? `Coeff ${formatWeight(exam.weight)}` : ' coursework',
|
||||
),
|
||||
h('span', { class: 'text-[10px] font-bold text-slate-500' }, formatDuration(exam.durationMin)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Fiche détaillée dans la modale. */
|
||||
export function examDetail(exam: Exam): HTMLElement {
|
||||
const meta = WEATHER[exam.kind]
|
||||
|
||||
const row = (icon: string, label: string, value: string | null | undefined): HTMLElement | null => {
|
||||
if (!value) return null
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex items-start gap-3 py-2.5' },
|
||||
h('i', { class: `fa-solid ${icon} text-slate-500 w-4 text-center mt-0.5` }),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'min-w-0' },
|
||||
h('p', { class: 'text-[10px] font-black uppercase tracking-wider text-slate-500' }, label),
|
||||
h('p', { class: 'text-sm font-bold text-slate-200 break-words' }, value),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'space-y-1' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center gap-3.5 pb-3' },
|
||||
h('div', { class: `w-14 h-14 rounded-2xl border flex items-center justify-center text-3xl ${meta.chip}` }, meta.icon),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'min-w-0' },
|
||||
h('h2', { class: 'text-xl font-black text-slate-100 leading-tight' }, exam.subject),
|
||||
h('p', { class: `text-xs font-black uppercase tracking-wider mt-0.5 ${meta.accent}` }, meta.title),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'h-px bg-slate-700/60' }),
|
||||
row('fa-solid fa-user-tie', 'Enseignant', exam.teacher),
|
||||
row('fa-solid fa-door-open', 'Salle', exam.room),
|
||||
row('fa-solid fa-book-open', 'Chapitre', exam.chapter),
|
||||
row('fa-solid fa-calendar-day', 'Date', longDate(new Date(exam.startsAt))),
|
||||
row('fa-solid fa-clock', 'Heure', formatTime(exam.startsAt)),
|
||||
row('fa-solid fa-hourglass-half', 'Durée', formatDuration(exam.durationMin)),
|
||||
row('fa-solid fa-gauge-high', 'Coefficient', exam.weight > 0 ? formatWeight(exam.weight) : ' coursework'),
|
||||
exam.note ? row('fa-solid fa-note-sticky', 'Note', exam.note) : null,
|
||||
h(
|
||||
'p',
|
||||
{ class: 'text-xs text-slate-400 font-medium leading-relaxed pt-3 mt-1 border-t border-slate-700/60' },
|
||||
meta.hint,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const weatherBadge = (kind: WeatherKind): HTMLElement =>
|
||||
h('span', { class: `inline-block px-3 py-1 rounded-xl text-xs font-black uppercase tracking-wider border ${WEATHER[kind].badge}` },
|
||||
`${WEATHER[kind].icon} ${WEATHER[kind].label}`)
|
||||
235
web/src/views/exam-form.ts
Normal file
235
web/src/views/exam-form.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { Exam, WeatherKind } from '@meteo-exam/shared'
|
||||
import { WEATHER, WEATHER_KINDS, WEIGHT_PRESETS, kindFromWeight } from '@meteo-exam/shared'
|
||||
import { h } from '../dom'
|
||||
import { isoDayInput } from '../dates'
|
||||
import { saveExam, setError } from '../store'
|
||||
import { showToast } from '../toast'
|
||||
|
||||
interface Draft {
|
||||
subject: string
|
||||
teacher: string
|
||||
room: string
|
||||
chapter: string
|
||||
day: string
|
||||
time: string
|
||||
durationMin: number
|
||||
weight: number
|
||||
kind: WeatherKind
|
||||
note: string
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors'
|
||||
|
||||
const labelClass = 'block text-[10px] font-black uppercase tracking-wider text-slate-500 mb-1.5'
|
||||
|
||||
function field(label: string, control: HTMLElement, hint?: string): HTMLElement {
|
||||
return h(
|
||||
'label',
|
||||
{ class: 'block' },
|
||||
h('span', { class: labelClass }, label),
|
||||
control,
|
||||
hint ? h('span', { class: 'block text-[10px] text-slate-600 font-semibold mt-1' }, hint) : null,
|
||||
)
|
||||
}
|
||||
|
||||
function draftFrom(exam: Exam | undefined): Draft {
|
||||
if (exam) {
|
||||
const date = new Date(exam.startsAt)
|
||||
return {
|
||||
subject: exam.subject,
|
||||
teacher: exam.teacher ?? '',
|
||||
room: exam.room ?? '',
|
||||
chapter: exam.chapter ?? '',
|
||||
day: isoDayInput(date),
|
||||
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
|
||||
durationMin: exam.durationMin,
|
||||
weight: exam.weight,
|
||||
kind: exam.kind,
|
||||
note: exam.note ?? '',
|
||||
}
|
||||
}
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
now.setHours(Math.max(8, now.getHours() + 1))
|
||||
return {
|
||||
subject: '',
|
||||
teacher: '',
|
||||
room: '',
|
||||
chapter: '',
|
||||
day: isoDayInput(now),
|
||||
time: `${String(now.getHours()).padStart(2, '0')}:00`,
|
||||
durationMin: 60,
|
||||
weight: 2,
|
||||
kind: 'orage',
|
||||
note: '',
|
||||
}
|
||||
}
|
||||
|
||||
function closeOverlay(overlay: HTMLElement): void {
|
||||
overlay.remove()
|
||||
}
|
||||
|
||||
/** Modale d'ajout / modification. `exam` absent = création. */
|
||||
export function openExamForm(exam: Exam | undefined, onSaved: () => void): void {
|
||||
const draft = draftFrom(exam)
|
||||
const editing = exam !== undefined
|
||||
|
||||
const overlay = h('div', {
|
||||
class: 'fixed inset-0 z-[95] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center',
|
||||
})
|
||||
|
||||
const errorBox = h('p', { class: 'hidden text-xs font-bold text-rose-300 bg-rose-500/10 border border-rose-500/25 rounded-xl px-3 py-2' })
|
||||
|
||||
const showError = (message: string): void => {
|
||||
errorBox.textContent = message
|
||||
errorBox.classList.remove('hidden')
|
||||
}
|
||||
|
||||
// --- Champs ---------------------------------------------------------------
|
||||
const subject = h('input', { class: inputClass, value: draft.subject, placeholder: 'Mathématiques' })
|
||||
const teacher = h('input', { class: inputClass, value: draft.teacher, placeholder: 'M. Roussel' })
|
||||
const room = h('input', { class: inputClass, value: draft.room, placeholder: 'B204' })
|
||||
const chapter = h('input', { class: inputClass, value: draft.chapter, placeholder: 'Chapitre ou thème' })
|
||||
const day = h('input', { type: 'date', class: inputClass, value: draft.day })
|
||||
const time = h('input', { type: 'time', class: inputClass, value: draft.time })
|
||||
const note = h('textarea', { class: `${inputClass} min-h-[70px] resize-none`, placeholder: 'Circonscription, calculatrice autorisée…' })
|
||||
note.value = draft.note
|
||||
|
||||
const duration = h('input', { type: 'number', class: inputClass, value: String(draft.durationMin), min: '5', max: '480', step: '5' })
|
||||
const weight = h('input', { type: 'number', class: inputClass, value: String(draft.weight), min: '0', max: '20', step: '0.5' })
|
||||
|
||||
// Le statut découle du coefficient tant qu'on ne le surcharge pas à la main.
|
||||
let kindOverride = false
|
||||
const kindPreview = h('div', { class: 'flex flex-wrap gap-1.5' })
|
||||
|
||||
const renderKinds = (): void => {
|
||||
kindPreview.replaceChildren(
|
||||
...WEATHER_KINDS.map((kind) => {
|
||||
const active = draft.kind === kind
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: `px-2.5 py-1.5 rounded-xl text-[11px] font-extrabold border transition-colors ${
|
||||
active ? WEATHER[kind].badge : 'bg-slate-800/60 border-slate-700 text-slate-400'
|
||||
}`,
|
||||
onclick: () => {
|
||||
draft.kind = kind
|
||||
kindOverride = true
|
||||
renderKinds()
|
||||
},
|
||||
},
|
||||
`${WEATHER[kind].icon} ${WEATHER[kind].label}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
weight.addEventListener('input', () => {
|
||||
const value = Number(weight.value)
|
||||
if (!Number.isFinite(value)) return
|
||||
draft.weight = value
|
||||
if (!kindOverride) {
|
||||
draft.kind = kindFromWeight(value)
|
||||
renderKinds()
|
||||
}
|
||||
})
|
||||
renderKinds()
|
||||
|
||||
// --- Actions --------------------------------------------------------------
|
||||
const submit = async (): Promise<void> => {
|
||||
if (!subject.value.trim()) {
|
||||
showError('La matière est obligatoire.')
|
||||
return
|
||||
}
|
||||
const startsAt = new Date(`${day.value}T${time.value || '08:00'}:00`)
|
||||
if (Number.isNaN(startsAt.getTime())) {
|
||||
showError('Date ou heure invalide.')
|
||||
return
|
||||
}
|
||||
const durationMin = Number(duration.value)
|
||||
if (!Number.isFinite(durationMin) || durationMin < 5) {
|
||||
showError('Durée minimum : 5 minutes.')
|
||||
return
|
||||
}
|
||||
|
||||
submitBtn.disabled = true
|
||||
submitBtn.classList.add('opacity-60')
|
||||
try {
|
||||
await saveExam(
|
||||
{
|
||||
subject: subject.value.trim(),
|
||||
teacher: teacher.value.trim() || null,
|
||||
room: room.value.trim() || null,
|
||||
chapter: chapter.value.trim() || null,
|
||||
startsAt: startsAt.toISOString(),
|
||||
durationMin,
|
||||
weight: Number(weight.value) || 0,
|
||||
kind: draft.kind,
|
||||
note: note.value.trim() || null,
|
||||
},
|
||||
exam?.id,
|
||||
)
|
||||
showToast(editing ? 'Évaluation mise à jour' : 'Évaluation ajoutée', 'success')
|
||||
closeOverlay(overlay)
|
||||
onSaved()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Enregistrement impossible')
|
||||
showError(error instanceof Error ? error.message : 'Enregistrement impossible')
|
||||
submitBtn.disabled = false
|
||||
submitBtn.classList.remove('opacity-60')
|
||||
}
|
||||
}
|
||||
|
||||
const submitBtn = h(
|
||||
'button',
|
||||
{ class: 'flex-1 bg-sky-500 text-slate-950 text-sm font-extrabold py-3 rounded-xl active:scale-95 transition-transform', onclick: submit },
|
||||
editing ? 'Enregistrer' : 'Ajouter au bulletin',
|
||||
)
|
||||
|
||||
const panel = h(
|
||||
'div',
|
||||
{ class: 'w-full sm:max-w-md bg-slate-900 border border-slate-700 rounded-t-[2rem] sm:rounded-[2rem] p-5 max-h-[90vh] overflow-y-auto no-scrollbar shadow-2xl' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-start justify-between gap-3 mb-4' },
|
||||
h(
|
||||
'div',
|
||||
{},
|
||||
h('h2', { class: 'text-lg font-black text-slate-100' }, editing ? 'Modifier l’évaluation' : 'Nouvelle évaluation'),
|
||||
h('p', { class: 'text-xs text-slate-400 font-semibold mt-0.5' }, 'Le statut météo découle du coefficient.'),
|
||||
),
|
||||
h('button', { class: 'text-slate-400 hover:text-slate-200 text-lg', onclick: () => closeOverlay(overlay) },
|
||||
h('i', { class: 'fa-solid fa-xmark' })),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'space-y-3.5' },
|
||||
field('Matière', subject),
|
||||
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Enseignant', teacher), field('Salle', room)),
|
||||
field('Chapitre', chapter),
|
||||
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Date', day), field('Heure', time)),
|
||||
h('div', { class: 'grid grid-cols-2 gap-3' }, field('Durée (min)', duration), field('Coefficient', weight)),
|
||||
field(
|
||||
'Statut météo',
|
||||
kindPreview,
|
||||
kindOverride ? 'Statut forcé manuellement.' : WEIGHT_PRESETS.find((p) => p.value === draft.weight)?.label,
|
||||
),
|
||||
field('Note', note),
|
||||
errorBox,
|
||||
h('div', { class: 'flex gap-2 pt-1' },
|
||||
h('button', { class: 'bg-slate-800 border border-slate-700 text-slate-300 text-sm font-extrabold px-4 py-3 rounded-xl active:scale-95 transition-transform', onclick: () => closeOverlay(overlay) },
|
||||
'Annuler'),
|
||||
submitBtn,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
overlay.append(panel)
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) closeOverlay(overlay)
|
||||
})
|
||||
document.body.append(overlay)
|
||||
subject.focus()
|
||||
}
|
||||
167
web/src/views/join.ts
Normal file
167
web/src/views/join.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { h, mount } from '../dom'
|
||||
import { api } from '../api'
|
||||
import { getBaseUrl, setBaseUrl } from '../config'
|
||||
import { loginAs } from '../store'
|
||||
import { showToast } from '../toast'
|
||||
import { DEFAULT_BASE_URL } from '../config'
|
||||
|
||||
const inputClass =
|
||||
'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-3 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'
|
||||
|
||||
type Mode = 'join' | 'create'
|
||||
|
||||
export function renderJoin(root: HTMLElement, rerender: () => void): void {
|
||||
let _mode: Mode = 'join'
|
||||
const getMode = (): Mode => _mode
|
||||
|
||||
const nameInput = h('input', { class: inputClass, placeholder: 'Terminale S2' })
|
||||
const levelInput = h('input', { class: inputClass, placeholder: 'Lycée' })
|
||||
const codeInput = h('input', { class: inputClass, placeholder: 'TERMS2', autocomplete: 'off' })
|
||||
const nicknameInput = h('input', { class: inputClass, placeholder: 'Votre pseudo' })
|
||||
const serverInput = h('input', {
|
||||
class: inputClass,
|
||||
placeholder: DEFAULT_BASE_URL,
|
||||
value: getBaseUrl(),
|
||||
autocomplete: 'off',
|
||||
inputMode: 'url',
|
||||
})
|
||||
|
||||
const errorBox = h('p', { class: 'hidden text-xs font-bold text-rose-300 bg-rose-500/10 border border-rose-500/25 rounded-xl px-3 py-2' })
|
||||
const codeHint = h('p', { class: 'hidden text-xs text-slate-500 font-semibold' })
|
||||
const submitBtn = h('button', {
|
||||
class: 'w-full bg-sky-500 text-slate-950 text-sm font-extrabold py-3.5 rounded-xl active:scale-[0.98] transition-transform',
|
||||
})
|
||||
const toggleBtn = h('button', {
|
||||
class: 'w-full text-xs font-black text-sky-400 py-2.5 active:scale-95 transition-transform',
|
||||
})
|
||||
|
||||
const showError = (message: string): void => {
|
||||
errorBox.textContent = message
|
||||
errorBox.classList.remove('hidden')
|
||||
}
|
||||
|
||||
const renderMode = (): void => {
|
||||
const create = getMode() === 'create'
|
||||
submitBtn.replaceChildren(
|
||||
create ? 'Créer la classe' : 'Rejoindre la classe',
|
||||
)
|
||||
toggleBtn.replaceChildren(
|
||||
create ? 'Déjà un code ? Rejoindre une classe' : 'Créer une nouvelle classe',
|
||||
)
|
||||
codeHint.classList.toggle('hidden', create)
|
||||
nameInput.parentElement?.classList.toggle('hidden', !create)
|
||||
levelInput.parentElement?.classList.toggle('hidden', !create)
|
||||
codeInput.parentElement?.classList.toggle('hidden', create)
|
||||
errorBox.classList.add('hidden')
|
||||
}
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const nickname = nicknameInput.value.trim()
|
||||
if (!nickname) {
|
||||
showError('Choisissez un pseudo.')
|
||||
return
|
||||
}
|
||||
const baseUrl = serverInput.value.trim().replace(/\/+$/, '') || DEFAULT_BASE_URL
|
||||
setBaseUrl(baseUrl)
|
||||
|
||||
submitBtn.disabled = true
|
||||
submitBtn.classList.add('opacity-60')
|
||||
try {
|
||||
if (getMode() === 'create') {
|
||||
const name = nameInput.value.trim()
|
||||
if (name.length < 2) {
|
||||
showError('Nom de classe trop court.')
|
||||
submitBtn.disabled = false
|
||||
submitBtn.classList.remove('opacity-60')
|
||||
return
|
||||
}
|
||||
const result = await api.createClass({ name, level: levelInput.value.trim() || null, nickname })
|
||||
showToast(`Classe créée — code ${result.code}`, 'success')
|
||||
await loginAs(result.token, { member: result.member, klass: result.klass })
|
||||
} else {
|
||||
const code = codeInput.value.trim()
|
||||
if (!code) {
|
||||
showError('Entrez le code de classe.')
|
||||
submitBtn.disabled = false
|
||||
submitBtn.classList.remove('opacity-60')
|
||||
return
|
||||
}
|
||||
const result = await api.joinClass({ code, nickname })
|
||||
await loginAs(result.token, { member: result.member, klass: result.klass })
|
||||
}
|
||||
rerender()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connexion impossible'
|
||||
showError(message)
|
||||
submitBtn.disabled = false
|
||||
submitBtn.classList.remove('opacity-60')
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.addEventListener('click', submit)
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
_mode = _mode === 'join' ? 'create' : 'join'
|
||||
renderMode()
|
||||
})
|
||||
codeInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
})
|
||||
nicknameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
})
|
||||
codeInput.addEventListener('input', () => {
|
||||
codeInput.value = codeInput.value.toUpperCase()
|
||||
})
|
||||
|
||||
renderMode()
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex flex-col items-center justify-center min-h-[70vh] px-6' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'w-20 h-20 rounded-[1.75rem] bg-gradient-to-tr from-sky-400 to-indigo-500 flex items-center justify-center text-white font-black text-3xl shadow-float mb-6' },
|
||||
'ME',
|
||||
),
|
||||
h('h1', { class: 'text-2xl font-black text-white text-center tracking-tight' }, 'Météo Exam'),
|
||||
h('p', { class: 'text-xs font-bold text-sky-400 mt-1 mb-8 text-center' },
|
||||
'Le calendrier des évaluations, en bulletin météo.'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'w-full space-y-4' },
|
||||
field('Votre pseudo', nicknameInput),
|
||||
getMode() === 'join' ? field('Code de classe', codeInput) : null,
|
||||
getMode() === 'join'
|
||||
? h('p', { class: 'text-[11px] text-slate-500 font-semibold -mt-2' },
|
||||
'Demander le code à l’admin de la classe (ex. TERMS2).')
|
||||
: null,
|
||||
getMode() === 'create' ? field('Nom de la classe', nameInput) : null,
|
||||
getMode() === 'create' ? field('Niveau (optionnel)', levelInput) : null,
|
||||
field('Serveur', serverInput, 'Adresse de l’API. Emulateur Android : 10.0.2.2:8787'),
|
||||
errorBox,
|
||||
submitBtn,
|
||||
toggleBtn,
|
||||
codeHint,
|
||||
),
|
||||
h(
|
||||
'p',
|
||||
{ class: 'text-[10px] text-slate-600 font-semibold mt-8 text-center' },
|
||||
'L’admin de la classe crée le calendrier. Les élèves le consultent et participent au radar.',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function field(label: string, control: HTMLElement, hint?: string): HTMLElement {
|
||||
return h(
|
||||
'label',
|
||||
{ class: 'block' },
|
||||
h('span', { class: labelClass }, label),
|
||||
control,
|
||||
hint ? h('span', { class: 'block text-[10px] text-slate-600 font-semibold mt-1' }, hint) : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
152
web/src/views/map.ts
Normal file
152
web/src/views/map.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { WEATHER } from '@meteo-exam/shared'
|
||||
import { h, mount } from '../dom'
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
|
||||
function svgEl<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number> = {},
|
||||
): SVGElementTagNameMap[K] {
|
||||
const el = document.createElementNS(SVG_NS, tag)
|
||||
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, String(value))
|
||||
return el
|
||||
}
|
||||
|
||||
interface Region {
|
||||
id: string
|
||||
subject: string
|
||||
kind: keyof typeof WEATHER
|
||||
weight: number
|
||||
desc: string
|
||||
d: string
|
||||
labelX: number
|
||||
labelY: number
|
||||
emojiX: number
|
||||
emojiY: number
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{ id: 'maths', subject: 'Mathématiques', kind: 'tempete', weight: 4, d: 'M 150,40 Q 250,20 350,50 L 320,160 Q 220,180 160,150 Z', labelX: 230, labelY: 120, emojiX: 230, emojiY: 90, desc: 'Avis de forte tempête. Contrôle continu à fort coefficient programmé.' },
|
||||
{ id: 'svt', subject: 'SVT', kind: 'vent', weight: 2, d: 'M 350,50 Q 460,80 440,200 L 310,220 Q 300,160 320,160 Z', labelX: 375, labelY: 160, emojiX: 375, emojiY: 130, desc: 'Risque de petit QCM surprise en début d’heure sur le chapitre de la géologie.' },
|
||||
{ id: 'physique', subject: 'Physique-Chimie', kind: 'brouillard', weight: 2, d: 'M 160,150 Q 220,180 310,220 L 280,320 Q 180,310 140,240 Z', labelX: 220, labelY: 255, emojiX: 220, emojiY: 225, desc: 'Suspicion de TP évalué la semaine prochaine. Rumeurs soutenues par la classe.' },
|
||||
{ id: 'histoire', subject: 'Histoire-Géo', kind: 'soleil', weight: 1, d: 'M 150,40 L 160,150 Q 140,240 60,220 Q 30,120 150,40 Z', labelX: 90, labelY: 160, emojiX: 90, emojiY: 130, desc: 'Conditions idéales. Aucun contrôle prévu d’ici la fin de la semaine.' },
|
||||
{ id: 'francais', subject: 'Français', kind: 'soleil', weight: 1, d: 'M 140,240 Q 180,310 280,320 L 260,380 Q 120,390 60,220 Z', labelX: 170, labelY: 340, emojiX: 170, emojiY: 315, desc: 'Aucune perturbation à signaler. Cours magistral standard.' },
|
||||
]
|
||||
|
||||
/** Carte SVG par matière — figée, alimentée par les données en phase 2. */
|
||||
export function renderMap(root: HTMLElement, _rerender: () => void): void {
|
||||
let selected = 'maths'
|
||||
|
||||
const detailTitle = h('h3', { class: 'text-base font-black text-slate-100' })
|
||||
const detailStatus = h('p', { class: 'text-xs font-bold' })
|
||||
const detailCoeff = h('span', { class: 'bg-slate-700 text-slate-200 text-xs font-black px-2.5 py-1 rounded-lg' })
|
||||
const detailDesc = h('p', { class: 'text-xs text-slate-300 font-medium leading-relaxed mt-2 pt-2 border-t border-slate-700/60' })
|
||||
const detailIcon = h('span', { class: 'text-3xl' })
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
tempete: 'text-rose-400',
|
||||
orage: 'text-rose-400',
|
||||
vent: 'text-amber-400',
|
||||
brouillard: 'text-purple-400',
|
||||
soleil: 'text-emerald-400',
|
||||
}
|
||||
|
||||
const updateDetail = (region: Region): void => {
|
||||
const meta = WEATHER[region.kind]
|
||||
detailIcon.textContent = meta.icon
|
||||
detailTitle.textContent = `Secteur : ${region.subject}`
|
||||
detailStatus.textContent = `${meta.title} • ${meta.hint}`
|
||||
detailStatus.className = `text-xs font-bold ${statusClasses[region.kind] ?? 'text-slate-400'}`
|
||||
detailCoeff.textContent = region.weight > 0 ? `Coeff ${region.weight.toFixed(1)}` : ' Calme'
|
||||
detailDesc.textContent = region.desc
|
||||
}
|
||||
|
||||
const paths = new Map<string, SVGPathElement>()
|
||||
const svg = svgEl('svg', { viewBox: '0 0 500 400' })
|
||||
svg.setAttribute('class', 'w-full h-full drop-shadow-md')
|
||||
|
||||
for (const region of REGIONS) {
|
||||
const meta = WEATHER[region.kind]
|
||||
const path = svgEl('path', {
|
||||
id: `region-${region.id}`,
|
||||
d: region.d,
|
||||
fill: meta.hex,
|
||||
})
|
||||
path.setAttribute('class', 'map-region')
|
||||
path.setAttribute('style', 'transition:all 0.3s cubic-bezier(0.4,0,0.2,1);cursor:pointer;stroke:#0f172a;stroke-width:2.5')
|
||||
|
||||
path.addEventListener('click', () => {
|
||||
selected = region.id
|
||||
for (const p of paths.values()) p.classList.remove('active-region')
|
||||
path.classList.add('active-region')
|
||||
updateDetail(region)
|
||||
})
|
||||
|
||||
paths.set(region.id, path)
|
||||
svg.append(path)
|
||||
|
||||
const emoji = svgEl('text', {
|
||||
x: region.emojiX,
|
||||
y: region.emojiY,
|
||||
'font-size': '22',
|
||||
'pointer-events': 'none',
|
||||
})
|
||||
emoji.textContent = meta.icon
|
||||
svg.append(emoji)
|
||||
|
||||
const label = svgEl('text', {
|
||||
x: region.labelX,
|
||||
y: region.labelY,
|
||||
'font-size': '12',
|
||||
'font-weight': '900',
|
||||
fill: '#ffffff',
|
||||
'text-anchor': 'middle',
|
||||
'pointer-events': 'none',
|
||||
})
|
||||
label.textContent = region.subject
|
||||
svg.append(label)
|
||||
}
|
||||
|
||||
paths.get(selected)?.classList.add('active-region')
|
||||
updateDetail(REGIONS[0]!)
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'space-y-4' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex justify-between items-center' },
|
||||
h('div', {},
|
||||
h('h2', { class: 'text-lg font-black text-slate-100' }, 'Carte Météorologique'),
|
||||
h('p', { class: 'text-xs font-semibold text-slate-400' }, 'Secteurs géographiques par matière'),
|
||||
),
|
||||
h('span', { class: 'px-2.5 py-1 rounded-full bg-sky-500/10 text-sky-400 text-xs font-bold border border-sky-500/20' },
|
||||
'Interactive'),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-slate-900 border border-slate-800 rounded-3xl p-3 shadow-lg relative overflow-hidden aspect-[4/3] flex items-center justify-center' },
|
||||
svg,
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' },
|
||||
h('div', { class: 'flex justify-between items-center mb-2' },
|
||||
h('div', { class: 'flex items-center gap-2.5' },
|
||||
detailIcon,
|
||||
h('div', {}, detailTitle, detailStatus),
|
||||
),
|
||||
detailCoeff,
|
||||
),
|
||||
detailDesc,
|
||||
),
|
||||
h(
|
||||
'p',
|
||||
{ class: 'text-[10px] text-slate-600 font-semibold text-center' },
|
||||
'Les secteurs seront alimentés par les données de la classe en phase 2.',
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
87
web/src/views/plus.ts
Normal file
87
web/src/views/plus.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { h, mount } from '../dom'
|
||||
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
|
||||
import { signOut } from '../store'
|
||||
import { showToast } from '../toast'
|
||||
|
||||
export function renderPlus(root: HTMLElement, rerender: () => void): void {
|
||||
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',
|
||||
value: getBaseUrl(),
|
||||
autocomplete: 'off',
|
||||
inputMode: 'url',
|
||||
})
|
||||
|
||||
const legendItems: { icon: string; title: string; accent: string; desc: string }[] = [
|
||||
{ icon: '☀️', title: 'Grand Soleil', accent: 'text-sky-400', desc: 'Journée calme. Aucun examen répertorié ni suspecté.' },
|
||||
{ icon: '🌫️', title: 'Brouillard', accent: 'text-purple-400', desc: 'Zone d’incertitude. Rumeur ou sous-entendu d’un professeur.' },
|
||||
{ icon: '💨', title: 'Coup de vent', accent: 'text-amber-400', desc: 'Interrogation surprise potentielle ou petit QCM (coeff faible).' },
|
||||
{ icon: '🌩️', title: 'Orage / Pluie', accent: 'text-rose-400', desc: 'Devoir Surveillé (DS) confirmé ou évaluation de chapitre.' },
|
||||
{ icon: '🚨', title: 'Alerte Rouge / Tempête', accent: 'text-red-500', desc: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.' },
|
||||
]
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'space-y-5' },
|
||||
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()
|
||||
},
|
||||
},
|
||||
'Enregistrer l’adresse',
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-4' },
|
||||
h('h3', { class: 'text-xs font-black text-slate-400 uppercase tracking-wider mb-1' }, 'Session'),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'w-full bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
|
||||
onclick: () => {
|
||||
signOut()
|
||||
rerender()
|
||||
},
|
||||
},
|
||||
h('i', { class: 'fa-solid fa-right-from-bracket mr-1.5' }),
|
||||
'Se déconnecter',
|
||||
),
|
||||
),
|
||||
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',
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
57
web/src/views/radar.ts
Normal file
57
web/src/views/radar.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { h, mount } from '../dom'
|
||||
|
||||
/** Radar de rumeurs — figé, branché sur le backend en phase 2. */
|
||||
export function renderRadar(root: HTMLElement, _rerender: () => void): void {
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'space-y-4' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-slate-800 border border-slate-700 rounded-2xl p-3.5 shadow-md' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex gap-3' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'w-9 h-9 rounded-full bg-sky-500/20 text-sky-400 flex items-center justify-center font-bold text-sm shrink-0' },
|
||||
h('i', { class: 'fa-solid fa-bullhorn' }),
|
||||
),
|
||||
h('input', {
|
||||
type: 'text',
|
||||
placeholder: 'Signaler une suspicion d’examen… (bientôt disponible)',
|
||||
disabled: true,
|
||||
class: 'w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2 text-sm text-slate-400 placeholder-slate-500 focus:outline-none',
|
||||
}),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex justify-between items-center mt-3 pt-2 border-t border-slate-700/50' },
|
||||
h('span', { class: 'text-xs text-slate-500 font-semibold' },
|
||||
h('i', { class: 'fa-solid fa-circle-info text-slate-500 mr-1' }),
|
||||
'Module à venir en phase 2'),
|
||||
h('button', {
|
||||
class: 'bg-slate-700 text-slate-400 px-4 py-1.5 rounded-xl font-extrabold text-xs',
|
||||
disabled: true,
|
||||
}, 'Publier'),
|
||||
),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-3 flex items-center justify-between text-xs text-indigo-300 font-bold' },
|
||||
h('span', {},
|
||||
h('i', { class: 'fa-solid fa-hand-pointer mr-1.5' }),
|
||||
'Glissez pour confirmer ou rejeter les rumeurs — bientôt !'),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'text-center py-16 px-6' },
|
||||
h('div', { class: 'text-5xl mb-4' }, '📡'),
|
||||
h('p', { class: 'text-sm font-black text-slate-200' }, 'Radar de rumeurs — phase 2'),
|
||||
h('p', { class: 'text-xs text-slate-400 font-semibold mt-1.5 leading-relaxed' },
|
||||
'Le module collaboratif (soumettre une rumeur, voter, conversion automatique au seuil de 65 %) sera branché sur le serveur dans la prochaine itération.'),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
312
web/src/views/week.ts
Normal file
312
web/src/views/week.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import type { Exam } from '@meteo-exam/shared'
|
||||
import { WEATHER } from '@meteo-exam/shared'
|
||||
import { h, mount } from '../dom'
|
||||
import {
|
||||
dayKey,
|
||||
dayNameShort,
|
||||
dayNumber,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
isSameDay,
|
||||
longDate,
|
||||
shortDate,
|
||||
} from '../dates'
|
||||
import { examCard, examDetail, weatherBadge } from './cards'
|
||||
import {
|
||||
dayStatus,
|
||||
examsByDay,
|
||||
getState,
|
||||
isAdmin,
|
||||
removeExam,
|
||||
selectDay,
|
||||
setError,
|
||||
} from '../store'
|
||||
import { showToast } from '../toast'
|
||||
import { openExamForm } from './exam-form'
|
||||
|
||||
/** Carte géante « Aujourd'hui » : l'évaluation la plus menaçante du jour. */
|
||||
function todayHero(onOpen: (exam: Exam) => void): HTMLElement {
|
||||
const today = new Date()
|
||||
const exams = examsByDay().get(dayKey(today)) ?? []
|
||||
|
||||
if (exams.length === 0) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'relative w-full bg-gradient-to-br from-sky-600 via-cyan-600 to-teal-600 rounded-[2.2rem] p-6 text-white shadow-float overflow-hidden border border-sky-400/30' },
|
||||
h('div', { class: 'absolute -right-8 -bottom-8 w-44 h-44 bg-white/20 rounded-full blur-2xl' }),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex justify-between items-start relative z-10' },
|
||||
h('span', { class: 'bg-black/30 backdrop-blur-md px-3.5 py-1 rounded-full text-xs font-black uppercase tracking-wider border border-white/10' },
|
||||
longDate(today)),
|
||||
h('span', { class: 'text-4xl' }, '☀️'),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'mt-6 relative z-10' },
|
||||
h('div', { class: 'inline-block bg-white/15 backdrop-blur-sm text-white text-xs font-black px-3 py-1 rounded-xl mb-2 border border-white/20' },
|
||||
'GRAND SOLEIL'),
|
||||
h('h2', { class: 'text-3xl font-black tracking-tight leading-none' }, 'Journée calme'),
|
||||
h('p', { class: 'text-sky-50 font-semibold text-sm mt-1.5 opacity-90' },
|
||||
'Aucune évaluation répertoriée. Profitez-en.'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const main = exams.reduce((worst, e) =>
|
||||
(e.weight > worst.weight ? e : worst), exams[0]!)
|
||||
const meta = WEATHER[main.kind]
|
||||
const gradients: Record<typeof main.kind, string> = {
|
||||
soleil: 'from-sky-600 via-cyan-600 to-teal-600',
|
||||
brouillard: 'from-purple-700 via-violet-600 to-slate-600',
|
||||
vent: 'from-amber-600 via-orange-600 to-rose-500',
|
||||
orage: 'from-rose-700 via-rose-600 to-orange-600',
|
||||
tempete: 'from-red-700 via-rose-600 to-amber-600',
|
||||
}
|
||||
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: `relative w-full text-left bg-gradient-to-br ${gradients[main.kind]} rounded-[2.2rem] p-6 text-white shadow-float overflow-hidden border border-white/20 active:scale-[0.99] transition-transform`,
|
||||
onclick: () => onOpen(main),
|
||||
},
|
||||
h('div', { class: 'absolute -right-8 -bottom-8 w-44 h-44 bg-yellow-300/20 rounded-full blur-2xl' }),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex justify-between items-start relative z-10' },
|
||||
h('span', { class: 'bg-black/30 backdrop-blur-md px-3.5 py-1 rounded-full text-xs font-black uppercase tracking-wider border border-white/10' },
|
||||
`Aujourd'hui • ${formatTime(main.startsAt)}`),
|
||||
h('span', { class: 'text-4xl' }, meta.icon),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'mt-6 relative z-10' },
|
||||
h('div', { class: 'inline-block bg-black/25 backdrop-blur-sm text-white text-xs font-black px-3 py-1 rounded-xl mb-2 border border-white/15' },
|
||||
meta.title.toUpperCase()),
|
||||
h('h2', { class: 'text-3xl font-black tracking-tight leading-none' }, main.subject),
|
||||
h('p', { class: 'text-amber-50 font-semibold text-sm mt-1.5 opacity-90' },
|
||||
main.chapter ?? (exams.length > 1 ? `+ ${exams.length - 1} autre(s) évaluation(s)` : meta.hint)),
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'mt-6 pt-4 border-t border-white/15 flex justify-between items-center relative z-10 text-xs font-bold' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center gap-2' },
|
||||
main.room ? h('span', { class: 'bg-white/20 px-3 py-1.5 rounded-xl' },
|
||||
h('i', { class: 'fa-solid fa-door-open mr-1.5' }), main.room) : null,
|
||||
h('span', { class: 'bg-white/20 px-3 py-1.5 rounded-xl' },
|
||||
h('i', { class: 'fa-solid fa-clock mr-1.5' }), formatDuration(main.durationMin)),
|
||||
),
|
||||
h('span', { class: 'bg-white text-slate-900 px-3 py-1.5 rounded-xl font-black shadow-sm' },
|
||||
`Coeff ${main.weight.toFixed(1)}`),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Frise horizontale des 7 prochains jours. */
|
||||
function weekStrip(): HTMLElement {
|
||||
const { stripDays, selectedDay } = getState()
|
||||
const today = new Date()
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex gap-2 overflow-x-auto no-scrollbar -mx-5 px-5 pb-1' },
|
||||
...stripDays.map((date) => {
|
||||
const key = dayKey(date)
|
||||
const status = dayStatus(key)
|
||||
const count = examsByDay().get(key)?.length ?? 0
|
||||
const isToday = isSameDay(date, today)
|
||||
const active = selectedDay === key
|
||||
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: `shrink-0 w-[3.75rem] rounded-2xl border p-2 flex flex-col items-center gap-1 transition-all active:scale-95 ${
|
||||
active
|
||||
? 'bg-slate-700/60 border-sky-400/60'
|
||||
: 'bg-slate-800/70 border-slate-700/60'
|
||||
}`,
|
||||
onclick: () => selectDay(key),
|
||||
},
|
||||
h('span', { class: `text-[9px] font-black tracking-wider ${isToday ? 'text-sky-400' : 'text-slate-500'}` },
|
||||
isToday ? "AUJ." : dayNameShort(date)),
|
||||
h('span', { class: 'text-lg font-black text-slate-100 leading-none' }, dayNumber(date)),
|
||||
h('span', { class: 'text-base leading-none' }, WEATHER[status].icon),
|
||||
h('span', { class: 'text-[9px] font-bold text-slate-500' },
|
||||
count === 0 ? '—' : `${count} éval.`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Liste groupée par jour, filtrée ou non par la frise. */
|
||||
function dayList(onOpen: (exam: Exam) => void): HTMLElement {
|
||||
const { selectedDay, stripDays } = getState()
|
||||
const visibleKeys = selectedDay ? [selectedDay] : stripDays.map(dayKey)
|
||||
const grouped = examsByDay()
|
||||
|
||||
const sections = visibleKeys
|
||||
.map((key) => {
|
||||
const exams = grouped.get(key) ?? []
|
||||
if (exams.length === 0) return null
|
||||
const [y, m, d] = key.split('-').map(Number)
|
||||
const date = new Date(y!, (m ?? 1) - 1, d ?? 1)
|
||||
return { key, date, exams }
|
||||
})
|
||||
.filter((s): s is { key: string; date: Date; exams: Exam[] } => s !== null)
|
||||
|
||||
if (sections.length === 0) {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'text-center py-10 px-6' },
|
||||
h('div', { class: 'text-4xl mb-3' }, WEATHER[dayStatus(selectedDay ?? dayKey(new Date()))].icon),
|
||||
h('p', { class: 'text-sm font-black text-slate-200' },
|
||||
selectedDay ? 'Grand Soleil' : 'Aucune évaluation sur les 7 jours'),
|
||||
h('p', { class: 'text-xs text-slate-400 font-semibold mt-1' },
|
||||
selectedDay ? 'Rien de prévu ce jour-là.' : 'Profitez du calme, ou ajoutez une évaluation si vous êtes admin.'),
|
||||
selectedDay
|
||||
? h('button', { class: 'mt-4 text-xs font-black text-sky-400', onclick: () => selectDay(null) },
|
||||
'Voir toute la semaine')
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'space-y-4' },
|
||||
...sections.map(({ key, date, exams }) =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'space-y-2.5' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-center gap-2.5 pt-1' },
|
||||
h('span', { class: 'text-sm font-black text-slate-200' }, shortDate(date)),
|
||||
weatherBadge(dayStatus(key)),
|
||||
),
|
||||
...exams.map((exam) => examCard(exam, onOpen)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Modale plein écran avec la fiche détaillée et les actions admin. */
|
||||
function openDetail(exam: Exam, rerender: () => void): void {
|
||||
const overlay = h('div', {
|
||||
class:
|
||||
'fixed inset-0 z-[90] bg-slate-950/80 backdrop-blur-sm flex items-end sm:items-center justify-center p-0 sm:p-6',
|
||||
})
|
||||
|
||||
const close = (): void => overlay.remove()
|
||||
|
||||
const 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-[88vh] overflow-y-auto no-scrollbar shadow-2xl' },
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex items-start justify-between gap-3 mb-4' },
|
||||
h('span', { class: 'text-[10px] font-black uppercase tracking-widest text-slate-500' }, 'Fiche d’examen'),
|
||||
h('button', { class: 'text-slate-400 hover:text-slate-200 text-lg', onclick: close },
|
||||
h('i', { class: 'fa-solid fa-xmark' })),
|
||||
),
|
||||
examDetail(exam),
|
||||
)
|
||||
|
||||
if (isAdmin()) {
|
||||
panel.append(
|
||||
h(
|
||||
'div',
|
||||
{ class: 'flex gap-2 mt-5 pt-4 border-t border-slate-700/60' },
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
|
||||
onclick: () => {
|
||||
close()
|
||||
openExamForm(exam, rerender)
|
||||
},
|
||||
},
|
||||
h('i', { class: 'fa-solid fa-pen mr-1.5' }), 'Modifier',
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'flex-1 bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs font-extrabold py-2.5 rounded-xl active:scale-95 transition-transform',
|
||||
onclick: async () => {
|
||||
close()
|
||||
try {
|
||||
await removeExam(exam.id)
|
||||
showToast('Évaluation supprimée', 'success')
|
||||
rerender()
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Suppression impossible')
|
||||
showToast('Suppression impossible', 'reject')
|
||||
}
|
||||
},
|
||||
},
|
||||
h('i', { class: 'fa-solid fa-trash mr-1.5' }), 'Supprimer',
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
overlay.append(panel)
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close()
|
||||
})
|
||||
document.body.append(overlay)
|
||||
}
|
||||
|
||||
export function renderWeek(root: HTMLElement, rerender: () => void): void {
|
||||
const { error, session } = getState()
|
||||
const open = (exam: Exam): void => openDetail(exam, rerender)
|
||||
|
||||
const children: (Node | null)[] = [
|
||||
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'),
|
||||
),
|
||||
weekStrip(),
|
||||
]
|
||||
|
||||
if (error) {
|
||||
children.push(
|
||||
h(
|
||||
'div',
|
||||
{ class: 'bg-rose-500/10 border border-rose-500/25 rounded-2xl p-3.5 flex items-center gap-2.5' },
|
||||
h('i', { class: 'fa-solid fa-triangle-exclamation text-rose-400' }),
|
||||
h('p', { class: 'text-xs font-bold text-rose-200 flex-1' }, error),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
children.push(dayList(open))
|
||||
|
||||
if (isAdmin()) {
|
||||
children.push(
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class:
|
||||
'w-full bg-sky-500/10 border border-sky-500/25 text-sky-300 text-sm font-extrabold py-3.5 rounded-2xl active:scale-[0.98] transition-transform',
|
||||
onclick: () => openExamForm(undefined, rerender),
|
||||
},
|
||||
h('i', { class: 'fa-solid fa-plus mr-2' }), 'Ajouter une évaluation',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
children.push(
|
||||
h(
|
||||
'p',
|
||||
{ class: 'text-[10px] text-slate-600 font-semibold text-center pt-2' },
|
||||
session ? `${session.klass.name} · ${getState().exams.length} évaluation(s) sur la période` : '',
|
||||
),
|
||||
)
|
||||
|
||||
mount(root, ...children.filter((c): c is Node => c !== null))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue