meteo-exam/web/src/views/join.ts
Mathis 6d154ed5fb 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)
2026-10-02 19:51:13 +02:00

167 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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