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)
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Dépendances
|
||||
node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
android/app/build/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
android/app/src/main/assets/public/
|
||||
*.apk
|
||||
|
||||
# Base de données
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
server/data/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
nohup.out
|
||||
109
README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Météo Exam
|
||||
|
||||
Le calendrier des évaluations scolaires, traduit en bulletin météorologique interactif.
|
||||
Application Android (Capacitor) + API Node.js.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
meteo-exam/
|
||||
├── shared/ Types + correspondance type d'éval ↔ météo
|
||||
├── server/ API Express + SQLite (classes, évaluations, SSE)
|
||||
├── web/ Front Vite + TypeScript (compilé dans Capacitor)
|
||||
│ └── android/ Projet Capacitor Android
|
||||
└── design/prototype/meteo-exam-reference.html Prototype visuel d'origine
|
||||
```
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### 1. Installer les dépendances
|
||||
|
||||
```bash
|
||||
cd meteo-exam
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Lancer le serveur
|
||||
|
||||
```bash
|
||||
# Base de démonstration (Terminale S2, code d'invitation TERMS2)
|
||||
npm run seed
|
||||
|
||||
# Serveur API (port 8787)
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
### 3. Lancer le front en développement
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
### 4. Builder l'APK Android
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npx cap sync android
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
# → android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Comptes de démonstration
|
||||
|
||||
| Rôle | Classe | Code | Pseudo |
|
||||
|------|--------|------|--------|
|
||||
| Admin | Terminale S2 | `TERMS2` | `Thomas S.` |
|
||||
| Élève | Terminale S2 | `TERMS2` | n'importe quel |
|
||||
|
||||
L'admin peut ajouter / modifier / supprimer les évaluations.
|
||||
Les élèves consultent et reçoivent les mises à jour en temps réel (SSE).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Serveur
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|----------|--------|-------------|
|
||||
| `PORT` | `8787` | Port d'écoute de l'API |
|
||||
| `METEO_DB` | `server/data/meteo-exam.db` | Chemin du fichier SQLite |
|
||||
| `METEO_ORIGINS` | `*` | Origines CORS autorisées (séparez par une virgule) |
|
||||
|
||||
### App Android
|
||||
|
||||
Dans l'onglet **Plus** de l'app, l'adresse du serveur est configurable.
|
||||
Sur l'émulateur Android, utilisez `http://10.0.2.2:8787` (l'hôte de la machine).
|
||||
Sur un téléphone physique, utilisez l'IP locale de la machine qui tourne le serveur.
|
||||
|
||||
## API
|
||||
|
||||
| Méthode | Route | Accès | Description |
|
||||
|---------|-------|-------|-------------|
|
||||
| `POST` | `/api/classes` | public | Créer une classe → `{ token, code, member, klass }` |
|
||||
| `POST` | `/api/classes/join` | public | Rejoindre via un code |
|
||||
| `GET` | `/api/classes/me` | membre | Session courante |
|
||||
| `GET` | `/api/exams?from&to` | membre | Liste des évaluations |
|
||||
| `POST` | `/api/exams` | admin | Ajouter une évaluation |
|
||||
| `PATCH` | `/api/exams/:id` | admin | Modifier |
|
||||
| `DELETE` | `/api/exams/:id` | admin | Supprimer |
|
||||
| `GET` | `/api/stream` | membre | Flux SSE (ping + exam.created/updated/deleted) |
|
||||
|
||||
Authentification : `Authorization: Bearer <token>` ou `?token=` (SSE uniquement).
|
||||
|
||||
## Statuts météo
|
||||
|
||||
| Icône | Nom | Coefficient | Signification |
|
||||
|-------|-----|-------------|---------------|
|
||||
| ☀️ | Grand Soleil | 0 | Aucune évaluation |
|
||||
| 🌫️ | Brouillard | — | Rumeur non confirmée |
|
||||
| 💨 | Coup de vent | 1 | Interro surprise, coeff faible |
|
||||
| 🌩️ | Orage | 2–3 | DS / contrôle continu |
|
||||
| 🚨 | Alerte Rouge | 4+ | Examen blanc / épreuve commune |
|
||||
|
||||
## Phase 2 (prévu)
|
||||
|
||||
- Radar de rumeurs (vote communautaire, seuil de conversion 65 %)
|
||||
- Métriques : pression atmosphérique (coefficient) + indice de vigilance 1-5
|
||||
- Flash info du jour généré automatiquement
|
||||
- PWA / service worker
|
||||
898
design/prototype/meteo-exam-reference.html
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr" class="h-full bg-slate-950 text-slate-100 font-sans">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Météo Exam - Application Mobile</title>
|
||||
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- FontAwesome Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<!-- Google Fonts: Nunito -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Nunito', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
appbg: '#0f172a',
|
||||
cardbg: '#1e293b',
|
||||
},
|
||||
boxShadow: {
|
||||
'ios': '0 20px 40px -15px rgba(0, 0, 0, 0.5)',
|
||||
'float': '0 10px 30px -5px rgba(239, 68, 68, 0.3)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #020617;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior-y: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hide Scrollbars */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* Mobile Phone Container styling */
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Swipeable Card Styling */
|
||||
.swipe-container {
|
||||
position: relative;
|
||||
height: 140px;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swipe-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
.swipe-bg-left { justify-content: flex-end; background-color: #e11d48; }
|
||||
.swipe-bg-right { justify-content: flex-start; background-color: #10b981; }
|
||||
|
||||
.swipe-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 1.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
touch-action: pan-y;
|
||||
z-index: 10;
|
||||
cursor: grab;
|
||||
will-change: transform;
|
||||
}
|
||||
.swipe-card:active { cursor: grabbing; }
|
||||
|
||||
/* SVG Map Interactions */
|
||||
.map-region {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
stroke: #0f172a;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
.map-region:hover, .map-region.active-region {
|
||||
filter: brightness(1.25);
|
||||
stroke: #38bdf8;
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
/* Tab Switch Animations */
|
||||
.tab-content {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Pulsing animations */
|
||||
@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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex items-center justify-center min-h-screen">
|
||||
|
||||
<div class="mobile-container">
|
||||
|
||||
<!-- Top Status / Header -->
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<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
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-extrabold text-white tracking-tight leading-none">Météo Exam</h1>
|
||||
<p class="text-xs font-bold text-sky-400 mt-0.5">Terminale S2 • Lycée</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-extrabold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
<span class="w-2 h-2 rounded-full bg-rose-500 animate-ping mr-1.5"></span>
|
||||
Direct
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Dynamic Scrollable Content -->
|
||||
<main class="flex-1 overflow-y-auto no-scrollbar p-5 pb-28 relative">
|
||||
|
||||
<!-- TAB 1: MÉTÉO (ACCUEIL) -->
|
||||
<section id="tab-meteo" class="tab-content active space-y-5">
|
||||
|
||||
<!-- Giant "Aujourd'hui" Card -->
|
||||
<div class="relative w-full bg-gradient-to-br from-rose-600 via-orange-600 to-amber-600 rounded-[2.2rem] p-6 text-white shadow-float overflow-hidden border border-rose-400/30">
|
||||
<!-- Background Glow Effect -->
|
||||
<div class="absolute -right-8 -bottom-8 w-44 h-44 bg-yellow-300/20 rounded-full blur-2xl"></div>
|
||||
|
||||
<div class="flex justify-between items-start relative z-10">
|
||||
<span class="bg-black/30 backdrop-blur-md text-white px-3.5 py-1 rounded-full text-xs font-black uppercase tracking-wider border border-white/10">
|
||||
Aujourd'hui • 08h30
|
||||
</span>
|
||||
<span class="inline-block text-4xl animate-pulse-glow">🌩️</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 relative z-10">
|
||||
<div class="inline-block bg-rose-950/50 backdrop-blur-sm text-rose-200 text-xs font-black px-3 py-1 rounded-xl mb-2 border border-rose-400/30">
|
||||
⚠️ ALERTE ROUGE
|
||||
</div>
|
||||
<h2 class="text-3xl font-black text-white tracking-tight leading-none">DS de Mathématiques</h2>
|
||||
<p class="text-amber-100 font-semibold text-sm mt-1.5 opacity-90">Analyse, Intégrales & Géométrie dans l'espace</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-white/15 flex justify-between items-center relative z-10 text-xs font-bold">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-white/20 px-3 py-1.5 rounded-xl"><i class="fa-solid fa-door-open mr-1.5"></i>Salle 204</span>
|
||||
<span class="bg-white/20 px-3 py-1.5 rounded-xl"><i class="fa-solid fa-clock mr-1.5"></i>2 Hours</span>
|
||||
</div>
|
||||
<span class="bg-white text-rose-700 px-3 py-1.5 rounded-xl font-black shadow-sm">Coeff 4.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Title -->
|
||||
<div class="flex justify-between items-center pt-2">
|
||||
<h3 class="text-base font-extrabold text-slate-200 uppercase tracking-wider">Prévisions de la semaine</h3>
|
||||
<span class="text-xs font-bold text-sky-400">7 prochains jours</span>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Forecast Cards -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Day 1 -->
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-2xl">
|
||||
💨
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-extrabold text-amber-400 uppercase tracking-wide">Demain • Vendredi</p>
|
||||
<h4 class="text-base font-bold text-slate-100">Physique-Chimie</h4>
|
||||
<p class="text-xs text-slate-400 font-semibold">Coup de vent : Interro surprise possible</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 rounded-lg bg-slate-700/50 text-slate-300 text-xs font-bold">Coeff 1.0</span>
|
||||
</div>
|
||||
|
||||
<!-- Day 2 -->
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-2xl">
|
||||
☀️
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-extrabold text-sky-400 uppercase tracking-wide">Lundi 5 Octobre</p>
|
||||
<h4 class="text-base font-bold text-slate-100">Français & Philosophie</h4>
|
||||
<p class="text-xs text-slate-400 font-semibold">Grand Soleil : Aucune évaluation répertoriée</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 rounded-lg bg-sky-500/10 text-sky-400 text-xs font-bold">Calme</span>
|
||||
</div>
|
||||
|
||||
<!-- Day 3 -->
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-2xl">
|
||||
🌫️
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-extrabold text-purple-400 uppercase tracking-wide">Mardi 6 Octobre</p>
|
||||
<h4 class="text-base font-bold text-slate-100">SVT</h4>
|
||||
<p class="text-xs text-slate-400 font-semibold">Brouillard : Rumeur de TP évalué</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 rounded-lg bg-slate-700/50 text-slate-300 text-xs font-bold">Coeff 2.0</span>
|
||||
</div>
|
||||
|
||||
<!-- Day 4 -->
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex items-center justify-center text-2xl">
|
||||
🌩️
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-extrabold text-rose-400 uppercase tracking-wide">Mercredi 7 Octobre</p>
|
||||
<h4 class="text-base font-bold text-slate-100">Histoire-Géographie</h4>
|
||||
<p class="text-xs text-slate-400 font-semibold">Orage : Contrôle sur la Guerre Froide</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 rounded-lg bg-slate-700/50 text-slate-300 text-xs font-bold">Coeff 3.0</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TAB 2: RADAR (RUMEURS) -->
|
||||
<section id="tab-radar" class="tab-content space-y-4">
|
||||
|
||||
<!-- Quick Post Card -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-3.5 shadow-md">
|
||||
<div class="flex gap-3">
|
||||
<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">
|
||||
<i class="fa-solid fa-bullhorn"></i>
|
||||
</div>
|
||||
<input type="text" id="rumor-input" placeholder="Signaler une suspicion d'examen..."
|
||||
class="w-full bg-slate-900/80 border border-slate-700/80 rounded-xl px-3.5 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 transition-colors">
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-3 pt-2 border-t border-slate-700/50">
|
||||
<span class="text-xs text-slate-400 font-semibold"><i class="fa-solid fa-circle-info text-sky-400 mr-1"></i>Anonyme pour la classe</span>
|
||||
<button onclick="postRumor()" class="bg-sky-500 hover:bg-sky-400 text-slate-950 px-4 py-1.5 rounded-xl font-extrabold text-xs shadow-md transition-all active:scale-95">
|
||||
Publier <i class="fa-solid fa-paper-plane ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swipe Tutorial Banner -->
|
||||
<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">
|
||||
<span><i class="fa-solid fa-hand-pointer mr-1.5"></i>Glisse vers la <strong>Droite</strong> pour Confirmer, <strong>Gauche</strong> pour Rejeter !</span>
|
||||
</div>
|
||||
|
||||
<!-- Rumors Feed Container -->
|
||||
<div id="radar-feed" class="space-y-4">
|
||||
|
||||
<!-- Swipeable Card 1 -->
|
||||
<div class="swipe-container" data-id="1">
|
||||
<div class="swipe-bg swipe-bg-left">
|
||||
<span><i class="fa-solid fa-xmark mr-2"></i>REJETER</span>
|
||||
</div>
|
||||
<div class="swipe-bg swipe-bg-right">
|
||||
<span>CONFIRMER<i class="fa-solid fa-check ml-2"></i></span>
|
||||
</div>
|
||||
<div class="swipe-card">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-purple-500 animate-pulse"></span>
|
||||
<span class="text-xs font-black text-purple-400 uppercase tracking-wider">Matière : Anglais</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 font-bold">Il y a 10 min</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-100 mt-1">« La prof a dit qu'on aurait une compréhension orale surprise cette semaine ! »</p>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-slate-700/60">
|
||||
<span class="text-xs text-slate-400 font-semibold">Certitude : <strong class="text-amber-400">68%</strong> (14 votes)</span>
|
||||
<!-- Fallback Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="resolveCardManual('1', 'rejected')" class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<button onclick="resolveCardManual('1', 'confirmed')" class="px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swipeable Card 2 -->
|
||||
<div class="swipe-container" data-id="2">
|
||||
<div class="swipe-bg swipe-bg-left">
|
||||
<span><i class="fa-solid fa-xmark mr-2"></i>REJETER</span>
|
||||
</div>
|
||||
<div class="swipe-bg swipe-bg-right">
|
||||
<span>CONFIRMER<i class="fa-solid fa-check ml-2"></i></span>
|
||||
</div>
|
||||
<div class="swipe-card">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
<span class="text-xs font-black text-amber-400 uppercase tracking-wider">Matière : Philosophie</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 font-bold">Il y a 45 min</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-100 mt-1">« Ramassage des dissertations à la fin de l'heure mardi prochain. »</p>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-slate-700/60">
|
||||
<span class="text-xs text-slate-400 font-semibold">Certitude : <strong class="text-emerald-400">85%</strong> (22 votes)</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="resolveCardManual('2', 'rejected')" class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<button onclick="resolveCardManual('2', 'confirmed')" class="px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swipeable Card 3 -->
|
||||
<div class="swipe-container" data-id="3">
|
||||
<div class="swipe-bg swipe-bg-left">
|
||||
<span><i class="fa-solid fa-xmark mr-2"></i>REJETER</span>
|
||||
</div>
|
||||
<div class="swipe-bg swipe-bg-right">
|
||||
<span>CONFIRMER<i class="fa-solid fa-check ml-2"></i></span>
|
||||
</div>
|
||||
<div class="swipe-card">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-rose-500 animate-pulse"></span>
|
||||
<span class="text-xs font-black text-rose-400 uppercase tracking-wider">Matière : Histoire</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 font-bold">Il y a 2h</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-100 mt-1">« Examen Blanc déplacé au mois prochain d'après le délégué. »</p>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-slate-700/60">
|
||||
<span class="text-xs text-slate-400 font-semibold">Certitude : <strong class="text-rose-400">32%</strong> (19 votes)</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="resolveCardManual('3', 'rejected')" class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<button onclick="resolveCardManual('3', 'confirmed')" class="px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TAB 3: LA CARTE (CARTE MÉTÉO INTERACTIVE) -->
|
||||
<section id="tab-carte" class="tab-content space-y-4">
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-lg font-black text-slate-100">Carte Météorologique</h2>
|
||||
<p class="text-xs font-semibold text-slate-400">Secteurs géographiques par matière</p>
|
||||
</div>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- SVG Interactive Map Container -->
|
||||
<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 viewBox="0 0 500 400" class="w-full h-full drop-shadow-md">
|
||||
<!-- Region 1: Maths (Nord / Red Alert) -->
|
||||
<path id="region-maths" d="M 150,40 Q 250,20 350,50 L 320,160 Q 220,180 160,150 Z"
|
||||
fill="#dc2626" class="map-region active-region" onclick="selectRegion('maths')" />
|
||||
<text x="230" y="90" font-size="22" class="pointer-events-none">🌩️</text>
|
||||
<text x="230" y="120" font-size="12" font-weight="900" fill="#ffffff" text-anchor="middle" class="pointer-events-none">MATHS</text>
|
||||
|
||||
<!-- Region 2: SVT (Est / Orange Wind) -->
|
||||
<path id="region-svt" d="M 350,50 Q 460,80 440,200 L 310,220 Q 300,160 320,160 Z"
|
||||
fill="#d97706" class="map-region" onclick="selectRegion('svt')" />
|
||||
<text x="375" y="130" font-size="22" class="pointer-events-none">💨</text>
|
||||
<text x="375" y="160" font-size="12" font-weight="900" fill="#ffffff" text-anchor="middle" class="pointer-events-none">SVT</text>
|
||||
|
||||
<!-- Region 3: Physique (Centre / Purple Fog) -->
|
||||
<path id="region-physique" d="M 160,150 Q 220,180 310,220 L 280,320 Q 180,310 140,240 Z"
|
||||
fill="#7c3aed" class="map-region" onclick="selectRegion('physique')" />
|
||||
<text x="220" y="225" font-size="22" class="pointer-events-none">🌫️</text>
|
||||
<text x="220" y="255" font-size="12" font-weight="900" fill="#ffffff" text-anchor="middle" class="pointer-events-none">PHYSIQUE</text>
|
||||
|
||||
<!-- Region 4: Histoire (Ouest / Calm Sun) -->
|
||||
<path id="region-histoire" d="M 150,40 L 160,150 Q 140,240 60,220 Q 30,120 150,40 Z"
|
||||
fill="#0284c7" class="map-region" onclick="selectRegion('histoire')" />
|
||||
<text x="90" y="130" font-size="22" class="pointer-events-none">☀️️</text>
|
||||
<text x="90" y="160" font-size="12" font-weight="900" fill="#ffffff" text-anchor="middle" class="pointer-events-none">HISTOIRE</text>
|
||||
|
||||
<!-- Region 5: Français (Sud / Calm Sun) -->
|
||||
<path id="region-francais" d="M 140,240 Q 180,310 280,320 L 260,380 Q 120,390 60,220 Z"
|
||||
fill="#059669" class="map-region" onclick="selectRegion('francais')" />
|
||||
<text x="170" y="315" font-size="22" class="pointer-events-none">☀️</text>
|
||||
<text x="170" y="340" font-size="12" font-weight="900" fill="#ffffff" text-anchor="middle" class="pointer-events-none">FRANÇAIS</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Selected Region Detail Box -->
|
||||
<div id="region-detail-box" class="bg-slate-800 border border-slate-700 rounded-2xl p-4 transition-all">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span id="region-icon" class="text-3xl">🌩️</span>
|
||||
<div>
|
||||
<h3 id="region-title" class="text-base font-black text-slate-100">Secteur : Mathématiques</h3>
|
||||
<p id="region-status" class="text-xs font-bold text-rose-400">Alerte Rouge • Perturbations Majeures</p>
|
||||
</div>
|
||||
</div>
|
||||
<span id="region-coeff" class="bg-slate-700 text-slate-200 text-xs font-black px-2.5 py-1 rounded-lg">Coeff 4.0</span>
|
||||
</div>
|
||||
<p id="region-desc" class="text-xs text-slate-300 font-medium leading-relaxed mt-2 pt-2 border-t border-slate-700/60">
|
||||
Avis de forte tempête pour la journée d'aujourd'hui. Contrôle continu à fort coefficient programmé. Prévisibilité maximale (98%).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- TAB 4: PLUS (...) (RÉGLAGES ET LÉGENDE) -->
|
||||
<section id="tab-plus" class="tab-content space-y-5">
|
||||
|
||||
<!-- User Profile Card -->
|
||||
<div class="bg-slate-800/90 border border-slate-700 rounded-2xl p-4 flex items-center justify-between shadow-sm">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-tr from-sky-400 to-indigo-500 text-white flex items-center justify-center font-black text-lg shadow-md">
|
||||
TS
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-black text-slate-100">Thomas S.</h3>
|
||||
<p class="text-xs text-slate-400 font-semibold">Délégué de classe • TS2</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 text-xs font-black border border-emerald-500/20">
|
||||
En ligne
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Toggles / Preferences -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-4 space-y-4">
|
||||
<h3 class="text-xs font-black text-slate-400 uppercase tracking-wider mb-1">Préférences</h3>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fa-solid fa-bell text-sky-400 text-base"></i>
|
||||
<span class="text-sm font-bold text-slate-200">Alertes "Alerte Rouge" Push</span>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="toggle-notifications" class="sr-only peer" checked onchange="toggleSetting('Notifications Push')">
|
||||
<div class="w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-sky-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-slate-700/60"></div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fa-solid fa-moon text-indigo-400 text-base"></i>
|
||||
<span class="text-sm font-bold text-slate-200">Mode Sombre Automatique</span>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="toggle-darkmode" class="sr-only peer" checked onchange="toggleSetting('Mode Sombre')">
|
||||
<div class="w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Legend Grid -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-xs font-black text-slate-400 uppercase tracking-wider">Légende Météorologique</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2.5">
|
||||
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3">
|
||||
<span class="text-3xl">☀️</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-black text-sky-400">Grand Soleil</h4>
|
||||
<p class="text-xs text-slate-300 font-medium">Journée calme. Aucun examen répertorié ni suspecté.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3">
|
||||
<span class="text-3xl">🌫️</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-black text-purple-400">Brouillard</h4>
|
||||
<p class="text-xs text-slate-300 font-medium">Zone d'incertitude. Rumeur ou sous-entendu d'un professeur.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3">
|
||||
<span class="text-3xl">💨</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-black text-amber-400">Coup de vent</h4>
|
||||
<p class="text-xs text-slate-300 font-medium">Interrogation surprise potentielle ou petit QCM (Coeff faible).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3">
|
||||
<span class="text-3xl">🌩️️</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-black text-rose-400">Orage / Pluie</h4>
|
||||
<p class="text-xs text-slate-300 font-medium">Devoir Surveillé (DS) confirmé ou évaluation de chapitre.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/80 border border-slate-700/60 rounded-xl p-3 flex items-center gap-3">
|
||||
<span class="text-3xl">🚨</span>
|
||||
<div>
|
||||
<h4 class="text-sm font-black text-red-500">Alerte Rouge / Tempête</h4>
|
||||
<p class="text-xs text-slate-300 font-medium">Examen blanc, épreuve commune ou gros devoir de fin de trimestre.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Fixed Bottom Navigation -->
|
||||
<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">
|
||||
|
||||
<button onclick="switchTab('meteo', this)" class="nav-btn flex flex-col items-center gap-1 text-sky-400 transition-colors">
|
||||
<i class="fa-solid fa-cloud-sun text-xl"></i>
|
||||
<span class="text-[10px] font-black tracking-wide">Météo</span>
|
||||
</button>
|
||||
|
||||
<button onclick="switchTab('radar', this)" class="nav-btn flex flex-col items-center gap-1 text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-satellite-dish text-xl"></i>
|
||||
<span class="absolute -top-1 -right-1 w-2 h-2 bg-rose-500 rounded-full"></span>
|
||||
</div>
|
||||
<span class="text-[10px] font-black tracking-wide">Radar</span>
|
||||
</button>
|
||||
|
||||
<button onclick="switchTab('carte', this)" class="nav-btn flex flex-col items-center gap-1 text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<i class="fa-solid fa-map-marked-alt text-xl"></i>
|
||||
<span class="text-[10px] font-black tracking-wide">La Carte</span>
|
||||
</button>
|
||||
|
||||
<button onclick="switchTab('plus', this)" class="nav-btn flex flex-col items-center gap-1 text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<i class="fa-solid fa-ellipsis-h text-xl"></i>
|
||||
<span class="text-[10px] font-black tracking-wide">Plus</span>
|
||||
</button>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- Floating Toast Notification -->
|
||||
<div id="toast" class="absolute top-20 left-1/2 transform -translate-x-1/2 bg-slate-800 text-slate-100 border border-slate-700 px-4 py-2 rounded-full font-bold text-xs shadow-2xl opacity-0 pointer-events-none transition-all duration-300 z-50 flex items-center gap-2">
|
||||
<i id="toast-icon" class="fa-solid fa-circle-check text-emerald-400"></i>
|
||||
<span id="toast-text">Notification</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- JavaScript Code Section -->
|
||||
<script>
|
||||
|
||||
// Switch Active Tab Function
|
||||
function switchTab(tabName, btnEl) {
|
||||
// Hide all tabs
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
|
||||
// Deactivate all nav buttons
|
||||
const navBtns = document.querySelectorAll('.nav-btn');
|
||||
navBtns.forEach(btn => {
|
||||
btn.classList.remove('text-sky-400');
|
||||
btn.classList.add('text-slate-500');
|
||||
});
|
||||
|
||||
// Show selected tab & highlight button
|
||||
const targetTab = document.getElementById('tab-' + tabName);
|
||||
if (targetTab) {
|
||||
targetTab.classList.add('active');
|
||||
}
|
||||
|
||||
if (btnEl) {
|
||||
btnEl.classList.remove('text-slate-500');
|
||||
btnEl.classList.add('text-sky-400');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Toast Notifications
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
const toastText = document.getElementById('toast-text');
|
||||
const toastIcon = document.getElementById('toast-icon');
|
||||
|
||||
toastText.innerText = message;
|
||||
|
||||
if (type === 'success') {
|
||||
toastIcon.className = 'fa-solid fa-circle-check text-emerald-400';
|
||||
} else if (type === 'reject') {
|
||||
toastIcon.className = 'fa-solid fa-circle-xmark text-rose-400';
|
||||
} else {
|
||||
toastIcon.className = 'fa-solid fa-circle-info text-sky-400';
|
||||
}
|
||||
|
||||
toast.classList.remove('opacity-0', 'pointer-events-none', '-translate-y-2');
|
||||
toast.classList.add('opacity-100', 'translate-y-0');
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('opacity-100', 'translate-y-0');
|
||||
toast.classList.add('opacity-0', 'pointer-events-none', '-translate-y-2');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
|
||||
// Swipe Card Gesture Class
|
||||
class SwipeCardController {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.card = container.querySelector('.swipe-card');
|
||||
this.bgLeft = container.querySelector('.swipe-bg-left');
|
||||
this.bgRight = container.querySelector('.swipe-bg-right');
|
||||
|
||||
this.startX = 0;
|
||||
this.currentX = 0;
|
||||
this.isDragging = false;
|
||||
this.threshold = 90; // swipe action threshold in px
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const onStart = (x) => {
|
||||
this.isDragging = true;
|
||||
this.startX = x;
|
||||
this.card.style.transition = 'none';
|
||||
};
|
||||
|
||||
const onMove = (x) => {
|
||||
if (!this.isDragging) return;
|
||||
this.currentX = x - this.startX;
|
||||
|
||||
// Move and rotate card
|
||||
this.card.style.transform = `translateX(${this.currentX}px) rotate(${this.currentX * 0.04}deg)`;
|
||||
|
||||
// Adjust background indicators opacity
|
||||
if (this.currentX > 0) {
|
||||
this.bgRight.style.opacity = Math.min(this.currentX / this.threshold, 1);
|
||||
this.bgLeft.style.opacity = '0';
|
||||
} else if (this.currentX < 0) {
|
||||
this.bgLeft.style.opacity = Math.min(Math.abs(this.currentX) / this.threshold, 1);
|
||||
this.bgRight.style.opacity = '0';
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
|
||||
|
||||
if (this.currentX > this.threshold) {
|
||||
this.resolve('confirmed');
|
||||
} else if (this.currentX < -this.threshold) {
|
||||
this.resolve('rejected');
|
||||
} else {
|
||||
this.reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Touch Listeners
|
||||
this.card.addEventListener('touchstart', (e) => onStart(e.touches[0].clientX), { passive: true });
|
||||
this.card.addEventListener('touchmove', (e) => onMove(e.touches[0].clientX), { passive: true });
|
||||
this.card.addEventListener('touchend', onEnd);
|
||||
|
||||
// Mouse Listeners
|
||||
this.card.addEventListener('mousedown', (e) => onStart(e.clientX));
|
||||
window.addEventListener('mousemove', (e) => { if (this.isDragging) onMove(e.clientX); });
|
||||
window.addEventListener('mouseup', () => { if (this.isDragging) onEnd(); });
|
||||
}
|
||||
|
||||
resolve(type) {
|
||||
const flyOutX = type === 'confirmed' ? 600 : -600;
|
||||
this.card.style.transform = `translateX(${flyOutX}px) rotate(${flyOutX * 0.04}deg)`;
|
||||
this.card.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
this.container.style.transition = 'all 0.3s ease';
|
||||
this.container.style.height = '0px';
|
||||
this.container.style.marginBottom = '0px';
|
||||
this.container.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
this.container.remove();
|
||||
showToast(type === 'confirmed' ? 'Rumeur confirmée !' : 'Rumeur rejetée !', type === 'confirmed' ? 'success' : 'reject');
|
||||
}, 300);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.card.style.transform = 'translateX(0px) rotate(0deg)';
|
||||
this.bgLeft.style.opacity = '0';
|
||||
this.bgRight.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Card Action (Fallback Buttons)
|
||||
function resolveCardManual(cardId, action) {
|
||||
const cardContainer = document.querySelector(`.swipe-container[data-id="${cardId}"]`);
|
||||
if (cardContainer && cardContainer.controller) {
|
||||
cardContainer.controller.resolve(action);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Data store for map regions
|
||||
const mapData = {
|
||||
maths: {
|
||||
title: "Secteur : Mathématiques",
|
||||
status: "Alerte Rouge • Perturbations Majeures",
|
||||
statusClass: "text-rose-400",
|
||||
icon: "🌩️",
|
||||
coeff: "Coeff 4.0",
|
||||
desc: "Avis de forte tempête pour la journée d'aujourd'hui. Devoir surveillé de 2 heures à fort coefficient programmé."
|
||||
},
|
||||
svt: {
|
||||
title: "Secteur : SVT",
|
||||
status: "Coup de vent • Rafales d'interro",
|
||||
statusClass: "text-amber-400",
|
||||
icon: "💨",
|
||||
coeff: "Coeff 2.0",
|
||||
desc: "Risque de petit QCM surprise en début d'heure sur le chapitre de la géologie. Soyez vigilants."
|
||||
},
|
||||
physique: {
|
||||
title: "Secteur : Physique-Chimie",
|
||||
status: "Brouillard • Visibilité réduite",
|
||||
statusClass: "text-purple-400",
|
||||
icon: "🌫️",
|
||||
coeff: "Coeff 2.0",
|
||||
desc: "Suspicion de TP évalué la semaine prochaine. Rumeurs soutenues par 68% de la classe."
|
||||
},
|
||||
histoire: {
|
||||
title: "Secteur : Histoire-Géographie",
|
||||
status: "Grand Soleil • Calme Plat",
|
||||
statusClass: "text-sky-400",
|
||||
icon: "☀️",
|
||||
coeff: "Coeff 1.0",
|
||||
desc: "Conditions idéales. Aucun contrôle prévu d'ici la fin de la semaine."
|
||||
},
|
||||
francais: {
|
||||
title: "Secteur : Français & Philo",
|
||||
status: "Grand Soleil • Découverte",
|
||||
statusClass: "text-emerald-400",
|
||||
icon: "☀️",
|
||||
coeff: "Coeff 1.0",
|
||||
desc: "Aucune perturbation à signaler. Cours magistral standard."
|
||||
}
|
||||
};
|
||||
|
||||
// Region Select Click Event
|
||||
function selectRegion(subjectKey) {
|
||||
// Remove active stroke from all regions
|
||||
document.querySelectorAll('.map-region').forEach(el => el.classList.remove('active-region'));
|
||||
|
||||
// Highlight target region
|
||||
const activePath = document.getElementById('region-' + subjectKey);
|
||||
if (activePath) {
|
||||
activePath.classList.add('active-region');
|
||||
}
|
||||
|
||||
// Update detail card
|
||||
const data = mapData[subjectKey];
|
||||
if (data) {
|
||||
document.getElementById('region-icon').innerText = data.icon;
|
||||
document.getElementById('region-title').innerText = data.title;
|
||||
document.getElementById('region-status').innerText = data.status;
|
||||
document.getElementById('region-status').className = `text-xs font-bold ${data.statusClass}`;
|
||||
document.getElementById('region-coeff').innerText = data.coeff;
|
||||
document.getElementById('region-desc').innerText = data.desc;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Post Rumor Function
|
||||
function postRumor() {
|
||||
const input = document.getElementById('rumor-input');
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text) {
|
||||
showToast('Veuillez écrire une rumeur', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const feed = document.getElementById('radar-feed');
|
||||
const newId = Date.now().toString();
|
||||
|
||||
const cardHTML = `
|
||||
<div class="swipe-container" data-id="${newId}">
|
||||
<div class="swipe-bg swipe-bg-left">
|
||||
<span><i class="fa-solid fa-xmark mr-2"></i>REJETER</span>
|
||||
</div>
|
||||
<div class="swipe-bg swipe-bg-right">
|
||||
<span>CONFIRMER<i class="fa-solid fa-check ml-2"></i></span>
|
||||
</div>
|
||||
<div class="swipe-card">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-sky-400 animate-pulse"></span>
|
||||
<span class="text-xs font-black text-sky-400 uppercase tracking-wider">Matière : Général</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 font-bold">À l'instant</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-100 mt-1">« ${text} »</p>
|
||||
<div class="flex justify-between items-center pt-2 border-t border-slate-700/60">
|
||||
<span class="text-xs text-slate-400 font-semibold">Certitude : <strong class="text-sky-400">50%</strong> (1 vote)</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="resolveCardManual('${newId}', 'rejected')" class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<button onclick="resolveCardManual('${newId}', 'confirmed')" class="px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs font-extrabold transition-colors">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
feed.insertAdjacentHTML('afterbegin', cardHTML);
|
||||
const newContainer = feed.querySelector(`.swipe-container[data-id="${newId}"]`);
|
||||
newContainer.controller = new SwipeCardController(newContainer);
|
||||
|
||||
input.value = '';
|
||||
showToast('Rumeur publiée avec succès !', 'success');
|
||||
}
|
||||
|
||||
// Toggle Settings Toast
|
||||
function toggleSetting(name) {
|
||||
showToast(`Réglage "${name}" mis à jour`, 'info');
|
||||
}
|
||||
|
||||
// Initialize all swipe cards on DOM load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.swipe-container').forEach(container => {
|
||||
container.controller = new SwipeCardController(container);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4684
package-lock.json
generated
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "meteo-exam",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Météo Exam — le calendrier des évaluations en bulletin météorologique",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,magenta \"npm:dev -w @meteo-exam/server\" \"npm:dev -w @meteo-exam/web\"",
|
||||
"dev:server": "npm run dev -w @meteo-exam/server",
|
||||
"dev:web": "npm run dev -w @meteo-exam/web",
|
||||
"seed": "npm run seed -w @meteo-exam/server",
|
||||
"build": "npm run build -w @meteo-exam/shared && npm run typecheck -w @meteo-exam/server && npm run build -w @meteo-exam/web",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"start": "npm run start -w @meteo-exam/server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
}
|
||||
28
server/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@meteo-exam/server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@meteo-exam/shared": "*",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
138
server/src/auth.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { randomBytes } from 'node:crypto'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import type { ClassInfo, Member, Role } from '@meteo-exam/shared'
|
||||
import { CLASS_CODE_LENGTH } from '@meteo-exam/shared'
|
||||
import { db, nowIso } from './db.js'
|
||||
|
||||
interface MemberRow {
|
||||
id: number
|
||||
class_id: number
|
||||
nickname: string
|
||||
role: Role
|
||||
class_name: string
|
||||
class_level: string | null
|
||||
}
|
||||
|
||||
export const newToken = (): string => randomBytes(32).toString('base64url')
|
||||
|
||||
/** Code d'invitation lisible au tableau : pas de 0/O ni de 1/I. */
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
export function newClassCode(): string {
|
||||
const codes = db.prepare<[], { code: string }>('SELECT code FROM classes').all()
|
||||
const taken = new Set(codes.map((r) => r.code))
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
let code = ''
|
||||
for (let i = 0; i < CLASS_CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[randomBytes(1)[0]! % CODE_ALPHABET.length]
|
||||
}
|
||||
if (!taken.has(code)) return code
|
||||
}
|
||||
throw new Error('Impossible de générer un code de classe unique')
|
||||
}
|
||||
|
||||
export interface NewSession {
|
||||
token: string
|
||||
member: Member
|
||||
klass: ClassInfo
|
||||
}
|
||||
|
||||
export function createClass(name: string, level: string | null, nickname: string): NewSession & { code: string } {
|
||||
const code = newClassCode()
|
||||
const now = nowIso()
|
||||
const token = newToken()
|
||||
const create = db.transaction((): NewSession => {
|
||||
const classId = db
|
||||
.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(name, level, code, now).lastInsertRowid as number
|
||||
const memberId = db
|
||||
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(classId, nickname, 'admin', token, now).lastInsertRowid as number
|
||||
return {
|
||||
token,
|
||||
member: { id: memberId, classId, nickname, role: 'admin' },
|
||||
klass: { id: classId, name, level },
|
||||
}
|
||||
})
|
||||
return { code, ...create() }
|
||||
}
|
||||
|
||||
export function joinClass(code: string, nickname: string): NewSession | null {
|
||||
const row = db
|
||||
.prepare<[string], { id: number; name: string; level: string | null }>(
|
||||
'SELECT id, name, level FROM classes WHERE code = ?',
|
||||
)
|
||||
.get(code)
|
||||
if (!row) return null
|
||||
const token = newToken()
|
||||
const memberId = db
|
||||
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(row.id, nickname, 'student', token, nowIso()).lastInsertRowid as number
|
||||
return {
|
||||
token,
|
||||
member: { id: memberId, classId: row.id, nickname, role: 'student' },
|
||||
klass: { id: row.id, name: row.name, level: row.level },
|
||||
}
|
||||
}
|
||||
|
||||
export function findSession(token: string): { member: Member; klass: ClassInfo } | null {
|
||||
const row = db
|
||||
.prepare<[string], MemberRow>(
|
||||
`SELECT m.id, m.class_id, m.nickname, m.role,
|
||||
c.name AS class_name, c.level AS class_level
|
||||
FROM members m
|
||||
JOIN classes c ON c.id = m.class_id
|
||||
WHERE m.token = ?`,
|
||||
)
|
||||
.get(token)
|
||||
if (!row) return null
|
||||
return {
|
||||
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role },
|
||||
klass: { id: row.class_id, name: row.class_name, level: row.class_level },
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
session?: { member: Member; klass: ClassInfo }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeton Bearer en en-tête `Authorization`. Sur Android le WebView Capacitor
|
||||
* est en cross-origin : un cookie httpOnly ne serait pas envoyé par le
|
||||
* navigateur embarqué, donc le client stocke le jeton et le renvoie ici.
|
||||
*
|
||||
* `?token=` est également accepté, car `EventSource` ne sait pas poser
|
||||
* d'en-têtes — c'est le seul moyen d'authentifier le flux SSE.
|
||||
*/
|
||||
function readToken(req: Request): string | null {
|
||||
const header = req.headers.authorization
|
||||
if (typeof header === 'string' && header.startsWith('Bearer ')) {
|
||||
return header.slice(7).trim() || null
|
||||
}
|
||||
const query = req.query['token']
|
||||
return typeof query === 'string' && query.length > 0 ? query : null
|
||||
}
|
||||
|
||||
export function requireSession(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = readToken(req)
|
||||
const session = token ? findSession(token) : null
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Session inconnue. Rejoignez une classe pour continuer.' })
|
||||
return
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
/** Réservé à l'admin de la classe (celui qui l'a créée). */
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session?.member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Seul l’admin de la classe peut modifier le calendrier.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
58
server/src/db.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db')
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true })
|
||||
|
||||
export const db = new Database(DB_PATH)
|
||||
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||
nickname TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin','student')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_members_token ON members(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_class ON members(class_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||
subject TEXT NOT NULL,
|
||||
teacher TEXT,
|
||||
room TEXT,
|
||||
chapter TEXT,
|
||||
starts_at TEXT NOT NULL,
|
||||
duration_min INTEGER NOT NULL,
|
||||
weight REAL NOT NULL DEFAULT 1,
|
||||
kind TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_by INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_class_start ON exams(class_id, starts_at);
|
||||
`)
|
||||
|
||||
export const nowIso = (): string => new Date().toISOString()
|
||||
|
||||
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */
|
||||
export const prepareChanges = db.prepare.bind(db)
|
||||
30
server/src/events.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Response } from 'express'
|
||||
import type { StreamEvent } from '@meteo-exam/shared'
|
||||
|
||||
/** clients SSE ouverts, groupés par classId. */
|
||||
const clients = new Map<number, Set<Response>>()
|
||||
|
||||
export function addClient(classId: number, res: Response): () => void {
|
||||
let set = clients.get(classId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
clients.set(classId, set)
|
||||
}
|
||||
set.add(res)
|
||||
|
||||
return () => {
|
||||
set.delete(res)
|
||||
if (set.size === 0) clients.delete(classId)
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcast(classId: number, event: StreamEvent): void {
|
||||
const set = clients.get(classId)
|
||||
if (!set) return
|
||||
const payload = `data: ${JSON.stringify(event)}\n\n`
|
||||
for (const res of set) {
|
||||
res.write(payload)
|
||||
}
|
||||
}
|
||||
|
||||
export const clientCount = (classId: number): number => clients.get(classId)?.size ?? 0
|
||||
68
server/src/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import express from 'express'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { classesRouter } from './routes/classes.js'
|
||||
import { examsRouter } from './routes/exams.js'
|
||||
import { streamRouter } from './routes/stream.js'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const PORT = Number(process.env['PORT'] ?? 8787)
|
||||
|
||||
/** Origines autorisées. L'app Android (WebView) est en cross-origin. */
|
||||
const ALLOW_ORIGINS = (process.env['METEO_ORIGINS'] ?? '*')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.use(express.json({ limit: '64kb' }))
|
||||
|
||||
// L'API ne renvoie aucun cookie : le client porte un jeton Bearer.
|
||||
// `Authorization` doit donc être autorisé explicitement.
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin
|
||||
res.set('Access-Control-Allow-Origin', ALLOW_ORIGINS.includes('*') ? (origin ?? '*') : (origin && ALLOW_ORIGINS.includes(origin) ? origin : ALLOW_ORIGINS[0] ?? '*'))
|
||||
res.set('Vary', 'Origin')
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Type')
|
||||
res.set('Access-Control-Max-Age', '600')
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end()
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.use('/api/classes', classesRouter)
|
||||
app.use('/api/exams', examsRouter)
|
||||
app.use('/api/stream', streamRouter)
|
||||
|
||||
app.use('/api', (_req, res) => {
|
||||
res.status(404).json({ error: 'Route inconnue' })
|
||||
})
|
||||
|
||||
// En production, le front compilé est servi par le même serveur. Utile pour
|
||||
// un déploiement web ; sur Android, l'app Capacitor charge ses assets locale.
|
||||
const webDist = resolve(here, '../../web/dist')
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist))
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.sendFile(resolve(webDist, 'index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Météo Exam — API sur http://localhost:${PORT}`)
|
||||
if (ALLOW_ORIGINS.includes('*')) {
|
||||
console.log('CORS ouvert à toutes les origines. Restreignez avec METEO_ORIGINS en production.')
|
||||
}
|
||||
if (!existsSync(webDist)) {
|
||||
console.log('Front non compilé : lancez `npm run dev` (Vite sur 5173) ou `npm run build`.')
|
||||
}
|
||||
})
|
||||
49
server/src/routes/classes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { createClass, joinClass, requireSession } from '../auth.js'
|
||||
|
||||
export const classesRouter = Router()
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(2, 'Nom de classe trop court').max(60),
|
||||
level: z.string().trim().max(40).nullish(),
|
||||
nickname: z.string().trim().min(1, 'Choisissez un pseudo').max(30),
|
||||
})
|
||||
|
||||
const joinSchema = z.object({
|
||||
code: z.string().trim().min(1, 'Entrez le code de classe').max(12),
|
||||
nickname: z.string().trim().min(1, 'Choisissez un pseudo').max(30),
|
||||
})
|
||||
|
||||
/** Crée une classe, ouvre une session admin et renvoie le jeton + le code d'invitation. */
|
||||
classesRouter.post('/', (req, res) => {
|
||||
const parsed = createSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const { name, level, nickname } = parsed.data
|
||||
const { code, token, member, klass } = createClass(name, level ?? null, nickname)
|
||||
res.status(201).json({ token, code, member, klass })
|
||||
})
|
||||
|
||||
/** Rejoint une classe existante via son code. */
|
||||
classesRouter.post('/join', (req, res) => {
|
||||
const parsed = joinSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const { code, nickname } = parsed.data
|
||||
const session = joinClass(code.toUpperCase(), nickname)
|
||||
if (!session) {
|
||||
res.status(404).json({ error: 'Code de classe inconnu. Vérifiez les caractères.' })
|
||||
return
|
||||
}
|
||||
res.status(201).json(session)
|
||||
})
|
||||
|
||||
/** Session courante : sert à valider un jeton au démarrage de l'app. */
|
||||
classesRouter.get('/me', requireSession, (req, res) => {
|
||||
res.json(req.session)
|
||||
})
|
||||
186
server/src/routes/exams.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { Exam, WeatherKind } from '@meteo-exam/shared'
|
||||
import { isWeatherKind, kindFromWeight } from '@meteo-exam/shared'
|
||||
import { db, nowIso } from '../db.js'
|
||||
import { requireSession, requireAdmin } from '../auth.js'
|
||||
import { broadcast } from '../events.js'
|
||||
|
||||
export const examsRouter = Router()
|
||||
|
||||
interface ExamRow {
|
||||
id: number
|
||||
class_id: number
|
||||
subject: string
|
||||
teacher: string | null
|
||||
room: string | null
|
||||
chapter: string | null
|
||||
starts_at: string
|
||||
duration_min: number
|
||||
weight: number
|
||||
kind: WeatherKind
|
||||
note: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const toExam = (row: ExamRow): Exam => ({
|
||||
id: row.id,
|
||||
classId: row.class_id,
|
||||
subject: row.subject,
|
||||
teacher: row.teacher,
|
||||
room: row.room,
|
||||
chapter: row.chapter,
|
||||
startsAt: row.starts_at,
|
||||
durationMin: row.duration_min,
|
||||
weight: row.weight,
|
||||
kind: row.kind,
|
||||
note: row.note,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
})
|
||||
|
||||
const bodySchema = z.object({
|
||||
subject: z.string().trim().min(1, 'La matière est obligatoire').max(60),
|
||||
teacher: z.string().trim().max(60).nullish(),
|
||||
room: z.string().trim().max(30).nullish(),
|
||||
chapter: z.string().trim().max(160).nullish(),
|
||||
startsAt: z.string().datetime({ message: 'Date invalide' }),
|
||||
durationMin: z.number().int().min(5, 'Durée minimum : 5 min').max(480, 'Durée maximum : 8 h'),
|
||||
weight: z.number().min(0).max(20),
|
||||
kind: z.enum(['soleil', 'brouillard', 'vent', 'orage', 'tempete']).nullish(),
|
||||
note: z.string().trim().max(400).nullish(),
|
||||
})
|
||||
|
||||
const patchSchema = bodySchema.partial()
|
||||
|
||||
/** Liste les évaluations d'une fenêtre temporelle, triées par date croissante. */
|
||||
examsRouter.get('/', requireSession, (req, res) => {
|
||||
const classId = req.session!.member.classId
|
||||
const from = typeof req.query['from'] === 'string' ? req.query['from'] : null
|
||||
const to = typeof req.query['to'] === 'string' ? req.query['to'] : null
|
||||
|
||||
const clauses = ['class_id = ?']
|
||||
const params: (string | number)[] = [classId]
|
||||
if (from) {
|
||||
clauses.push('starts_at >= ?')
|
||||
params.push(from)
|
||||
}
|
||||
if (to) {
|
||||
clauses.push('starts_at < ?')
|
||||
params.push(to)
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare<unknown[], ExamRow>(
|
||||
`SELECT * FROM exams WHERE ${clauses.join(' AND ')} ORDER BY starts_at ASC`,
|
||||
)
|
||||
.all(...params)
|
||||
res.json(rows.map(toExam))
|
||||
})
|
||||
|
||||
examsRouter.post('/', requireSession, requireAdmin, (req, res) => {
|
||||
const parsed = bodySchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const data = parsed.data
|
||||
const now = nowIso()
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO exams
|
||||
(class_id, subject, teacher, room, chapter, starts_at, duration_min, weight, kind, note, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
req.session!.member.classId,
|
||||
data.subject,
|
||||
data.teacher ?? null,
|
||||
data.room ?? null,
|
||||
data.chapter ?? null,
|
||||
new Date(data.startsAt).toISOString(),
|
||||
data.durationMin,
|
||||
data.weight,
|
||||
data.kind ?? kindFromWeight(data.weight),
|
||||
data.note ?? null,
|
||||
req.session!.member.id,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
|
||||
const examId = Number(info.lastInsertRowid)
|
||||
const row = db.prepare<[number], ExamRow>('SELECT * FROM exams WHERE id = ?').get(examId)!
|
||||
const exam = toExam(row)
|
||||
broadcast(exam.classId, { type: 'exam.created', exam })
|
||||
res.status(201).json(exam)
|
||||
})
|
||||
|
||||
examsRouter.patch('/:id', requireSession, requireAdmin, (req, res) => {
|
||||
const id = Number(req.params['id'])
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Identifiant invalide' })
|
||||
return
|
||||
}
|
||||
const existing = db
|
||||
.prepare<[number, number], ExamRow>('SELECT * FROM exams WHERE id = ? AND class_id = ?')
|
||||
.get(id, req.session!.member.classId)
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Évaluation introuvable' })
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = patchSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const d = parsed.data
|
||||
const nextWeight = d.weight ?? existing.weight
|
||||
const kind = d.kind ?? (d.weight !== undefined ? kindFromWeight(nextWeight) : existing.kind)
|
||||
if (!isWeatherKind(kind)) {
|
||||
res.status(400).json({ error: 'Statut météo inconnu' })
|
||||
return
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE exams
|
||||
SET subject = ?, teacher = ?, room = ?, chapter = ?, starts_at = ?,
|
||||
duration_min = ?, weight = ?, kind = ?, note = ?, updated_at = ?
|
||||
WHERE id = ? AND class_id = ?`,
|
||||
).run(
|
||||
d.subject ?? existing.subject,
|
||||
d.teacher === undefined ? existing.teacher : d.teacher,
|
||||
d.room === undefined ? existing.room : d.room,
|
||||
d.chapter === undefined ? existing.chapter : d.chapter,
|
||||
d.startsAt ? new Date(d.startsAt).toISOString() : existing.starts_at,
|
||||
d.durationMin ?? existing.duration_min,
|
||||
nextWeight,
|
||||
kind,
|
||||
d.note === undefined ? existing.note : d.note,
|
||||
nowIso(),
|
||||
id,
|
||||
req.session!.member.classId,
|
||||
)
|
||||
|
||||
const row = db.prepare<[number], ExamRow>('SELECT * FROM exams WHERE id = ?').get(id)!
|
||||
const exam = toExam(row)
|
||||
broadcast(exam.classId, { type: 'exam.updated', exam })
|
||||
res.json(exam)
|
||||
})
|
||||
|
||||
examsRouter.delete('/:id', requireSession, requireAdmin, (req, res) => {
|
||||
const id = Number(req.params['id'])
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Identifiant invalide' })
|
||||
return
|
||||
}
|
||||
const classId = req.session!.member.classId
|
||||
const info = db.prepare<[number, number], { changes: number }>('DELETE FROM exams WHERE id = ? AND class_id = ?').run(id, classId)
|
||||
if (info.changes === 0) {
|
||||
res.status(404).json({ error: 'Évaluation introuvable' })
|
||||
return
|
||||
}
|
||||
broadcast(classId, { type: 'exam.deleted', examId: id })
|
||||
res.status(204).end()
|
||||
})
|
||||
40
server/src/routes/stream.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Router } from 'express'
|
||||
import { requireSession } from '../auth.js'
|
||||
import { addClient } from '../events.js'
|
||||
import type { StreamEvent } from '@meteo-exam/shared'
|
||||
|
||||
export const streamRouter = Router()
|
||||
|
||||
const send = (res: import('express').Response, event: StreamEvent): void => {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux SSE du calendrier. Le cookie de session est lu au handshake, ce qui
|
||||
* évite un jeton en query string (et le partage d'URL cassé par EventSource).
|
||||
*/
|
||||
streamRouter.get('/', requireSession, (req, res) => {
|
||||
const classId = req.session!.member.classId
|
||||
|
||||
res.status(200)
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
res.flushHeaders()
|
||||
|
||||
// Commente d'ouverture : certains proxys n'envoient rien avant le premier event.
|
||||
res.write(': connected\n\n')
|
||||
send(res, { type: 'ping' })
|
||||
|
||||
const remove = addClient(classId, res)
|
||||
const keepAlive = setInterval(() => send(res, { type: 'ping' }), 25_000)
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(keepAlive)
|
||||
remove()
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
130
server/src/seed.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Jeu de données de démonstration : la classe Terminale S2 avec deux semaines
|
||||
* d'évaluations. Idempotent — relancer `npm run seed` ne duplique rien.
|
||||
* Usage : npm run seed [-- --reset]
|
||||
*/
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { db, nowIso } from './db.js'
|
||||
import { newToken } from './auth.js'
|
||||
import type { WeatherKind } from '@meteo-exam/shared'
|
||||
|
||||
const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db')
|
||||
|
||||
if (process.argv.includes('--reset')) {
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const file = `${DB_PATH}${suffix}`
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
console.log('Base effacée. Relancez `npm run seed`.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const existing = db.prepare<[], { n: number }>('SELECT COUNT(*) AS n FROM classes').get()!
|
||||
if (existing.n > 0) {
|
||||
console.log('La base contient déjà des classes — seed ignoré. Utilisez --reset pour repartir de zéro.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const CLASS_NAME = 'Terminale S2'
|
||||
const LEVEL = 'Lycée'
|
||||
const ADMIN_NICK = 'Thomas S.'
|
||||
const CLASS_CODE = 'TERMS2'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const isWeekend = (d: Date): boolean => d.getDay() === 0 || d.getDay() === 6
|
||||
|
||||
const midnight = new Date()
|
||||
midnight.setHours(0, 0, 0, 0)
|
||||
|
||||
/**
|
||||
* `dayOffset` compte les jours **scolaires** : 0 = aujourd'hui (même si c'est
|
||||
* samedi, le seed reste lisible), puis on saute les weekends.
|
||||
*/
|
||||
function schoolDay(offset: number): Date {
|
||||
const date = new Date(midnight)
|
||||
let counted = 0
|
||||
for (let step = 0; step < 90; step++) {
|
||||
if (counted === offset) break
|
||||
date.setTime(date.getTime() + DAY_MS)
|
||||
if (!isWeekend(date)) counted++
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
interface SeedExam {
|
||||
dayOffset: number
|
||||
hour: number
|
||||
subject: string
|
||||
teacher: string | null
|
||||
room: string | null
|
||||
chapter: string | null
|
||||
durationMin: number
|
||||
weight: number
|
||||
kind: WeatherKind
|
||||
note: string | null
|
||||
}
|
||||
|
||||
const exams: SeedExam[] = [
|
||||
{ dayOffset: 0, hour: 8, subject: 'Mathématiques', teacher: 'M. Roussel', room: 'B204', chapter: 'Intégrales, primitives et dérivation', durationMin: 120, weight: 4, kind: 'tempete', note: 'Calculatrice autorisée, calculatrice graphique non.' },
|
||||
{ dayOffset: 0, hour: 14, subject: 'Histoire-Géographie', teacher: 'Mme Ferrand', room: 'A112', chapter: null, durationMin: 55, weight: 1, kind: 'vent', note: 'Interro surprise probable en début de cours.' },
|
||||
{ dayOffset: 1, hour: 9, subject: 'Physique-Chimie', teacher: 'Mme Nguyen', room: 'Labo 3', chapter: 'Mécanique : second principe', durationMin: 60, weight: 2, kind: 'orage', note: 'Paquet de calculatrices fourni.' },
|
||||
{ dayOffset: 1, hour: 15, subject: 'Anglais', teacher: 'M. Whitfield', room: 'C007', chapter: 'Compréhension orale — Unit 4', durationMin: 30, weight: 1, kind: 'vent', note: 'Rumeur d’une dictée surprise.' },
|
||||
{ dayOffset: 2, hour: 8, subject: 'SVT', teacher: 'Mme Perrin', room: 'S215', chapter: 'Génétique des populations', durationMin: 90, weight: 3, kind: 'orage', note: 'TP suivi d’une synthèse écrite.' },
|
||||
{ dayOffset: 3, hour: 10, subject: 'Philosophie', teacher: 'M. Aubert', room: 'A008', chapter: 'La raison et le doute', durationMin: 120, weight: 3, kind: 'orage', note: 'Dissertation, sujet au choix.' },
|
||||
{ dayOffset: 3, hour: 16, subject: 'EPS', teacher: 'Mme Costa', room: 'Gymnase', chapter: null, durationMin: 60, weight: 1, kind: 'soleil', note: 'Basket obligatoire.' },
|
||||
{ dayOffset: 4, hour: 9, subject: 'Mathématiques', teacher: 'M. Roussel', room: 'B204', chapter: 'Probabilités conditionnelles', durationMin: 60, weight: 2, kind: 'orage', note: null },
|
||||
{ dayOffset: 5, hour: 10, subject: 'Français', teacher: 'Mme Dumas', room: 'A101', chapter: 'Guerre froide — contexte', durationMin: 60, weight: 1, kind: 'vent', note: 'Lecture suivie d’une étude de texte.' },
|
||||
{ dayOffset: 6, hour: 8, subject: 'Examen blanc', teacher: null, room: 'Amphi A', chapter: 'Toutes matières', durationMin: 180, weight: 5, kind: 'tempete', note: 'Blanc général, convocation à 8h précises.' },
|
||||
{ dayOffset: 7, hour: 9, subject: 'SVT', teacher: 'Mme Perrin', room: 'S215', chapter: 'Écosystèmes et bilan de matière', durationMin: 45, weight: 1, kind: 'brouillard', note: 'Brouillard : TP évalué, date encore floue.' },
|
||||
{ dayOffset: 8, hour: 14, subject: 'Anglais', teacher: 'M. Whitfield', room: 'C007', chapter: 'Expression écrite — sujet d’invention', durationMin: 60, weight: 2, kind: 'orage', note: null },
|
||||
{ dayOffset: 9, hour: 8, subject: 'Histoire-Géographie', teacher: 'Mme Ferrand', room: 'A112', chapter: 'La décolonisation', durationMin: 60, weight: 2, kind: 'orage', note: null },
|
||||
{ dayOffset: 10, hour: 10, subject: 'Philosophie', teacher: 'M. Aubert', room: 'A008', chapter: 'Le sujet et l’autre', durationMin: 60, weight: 2, kind: 'orage', note: 'Plan imposé.' },
|
||||
]
|
||||
|
||||
const seed = db.transaction(() => {
|
||||
db.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(CLASS_NAME, LEVEL, CLASS_CODE, nowIso())
|
||||
|
||||
const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?')
|
||||
.get(CLASS_CODE)!.id
|
||||
|
||||
const adminToken = newToken()
|
||||
const adminId = db
|
||||
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(classId, ADMIN_NICK, 'admin', adminToken, nowIso()).lastInsertRowid as number
|
||||
|
||||
// Quelques élèves : ils donneront du volume aux votes du radar plus tard.
|
||||
for (const nick of ['Léa M.', 'Karim B.', 'Chloé D.', 'Inès R.', 'Lucas P.', 'Mehdi A.']) {
|
||||
db.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(classId, nick, 'student', newToken(), nowIso())
|
||||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO exams
|
||||
(class_id, subject, teacher, room, chapter, starts_at, duration_min, weight, kind, note, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
|
||||
for (const e of exams) {
|
||||
const date = schoolDay(e.dayOffset)
|
||||
date.setHours(e.hour, 0, 0, 0)
|
||||
const now = nowIso()
|
||||
insert.run(
|
||||
classId, e.subject, e.teacher, e.room, e.chapter,
|
||||
date.toISOString(), e.durationMin, e.weight, e.kind, e.note,
|
||||
adminId, now, now,
|
||||
)
|
||||
}
|
||||
|
||||
return { classId, adminToken }
|
||||
})
|
||||
|
||||
const { adminToken } = seed()
|
||||
|
||||
console.log('Seed terminé.')
|
||||
console.log(` Classe : ${CLASS_NAME} (${LEVEL})`)
|
||||
console.log(` Code invite : ${CLASS_CODE}`)
|
||||
console.log(` Admin : ${ADMIN_NICK}`)
|
||||
console.log(` Évaluations : ${exams.length}`)
|
||||
console.log(` Jeton admin : ${adminToken}`)
|
||||
20
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
shared/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@meteo-exam/shared",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
175
shared/src/index.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Types et métadonnées partagés entre le serveur et le front.
|
||||
* Aucun import runtime ici : ce module est consommé tel quel par les deux côtés.
|
||||
*/
|
||||
|
||||
/** Statut météorologique d'une évaluation. */
|
||||
export type WeatherKind = 'soleil' | 'brouillard' | 'vent' | 'orage' | 'tempete'
|
||||
|
||||
export const WEATHER_KINDS: WeatherKind[] = ['soleil', 'brouillard', 'vent', 'orage', 'tempete']
|
||||
|
||||
/** Du plus rassurant au plus menaçant. Sert aux tris et au calcul du statut d'une journée. */
|
||||
export const SEVERITY: Record<WeatherKind, number> = {
|
||||
soleil: 0,
|
||||
brouillard: 1,
|
||||
vent: 2,
|
||||
orage: 3,
|
||||
tempete: 4,
|
||||
}
|
||||
|
||||
export interface WeatherMeta {
|
||||
/** Nom affiché, ex. « Alerte Rouge ». */
|
||||
label: string
|
||||
/** Titre long pour les modales et la légende. */
|
||||
title: string
|
||||
icon: string
|
||||
/** Description pédagogique affichée dans la légende. */
|
||||
hint: string
|
||||
/** Couleur d'accent en dur, pour les SVG et jauges. */
|
||||
hex: string
|
||||
/** Classes Tailwind de la pastille (fond / bordure / texte). */
|
||||
chip: string
|
||||
/** Classe de couleur du texte seule, pour les libellés sur fond neutre. */
|
||||
accent: string
|
||||
/** Classes Tailwind du badge plein. */
|
||||
badge: string
|
||||
}
|
||||
|
||||
export const WEATHER: Record<WeatherKind, WeatherMeta> = {
|
||||
soleil: {
|
||||
label: 'Grand Soleil',
|
||||
title: 'Grand Soleil',
|
||||
icon: '☀️',
|
||||
hint: 'Journée calme. Aucun examen répertorié ni suspecté.',
|
||||
hex: '#0284c7',
|
||||
chip: 'bg-sky-500/10 border-sky-500/20 text-sky-400',
|
||||
accent: 'text-sky-400',
|
||||
badge: 'bg-sky-500/15 text-sky-300 border-sky-400/30',
|
||||
},
|
||||
brouillard: {
|
||||
label: 'Brouillard',
|
||||
title: 'Brouillard',
|
||||
icon: '🌫️',
|
||||
hint: 'Zone d’incertitude. Rumeur ou sous-entendu d’un professeur.',
|
||||
hex: '#7c3aed',
|
||||
chip: 'bg-purple-500/10 border-purple-500/20 text-purple-400',
|
||||
accent: 'text-purple-400',
|
||||
badge: 'bg-purple-500/15 text-purple-300 border-purple-400/30',
|
||||
},
|
||||
vent: {
|
||||
label: 'Coup de vent',
|
||||
title: 'Coup de vent',
|
||||
icon: '💨',
|
||||
hint: 'Interrogation surprise potentielle ou petit QCM, coefficient faible.',
|
||||
hex: '#d97706',
|
||||
chip: 'bg-amber-500/10 border-amber-500/20 text-amber-400',
|
||||
accent: 'text-amber-400',
|
||||
badge: 'bg-amber-500/15 text-amber-300 border-amber-400/30',
|
||||
},
|
||||
orage: {
|
||||
label: 'Orage',
|
||||
title: 'Orage / Pluie battante',
|
||||
icon: '🌩️',
|
||||
hint: 'Devoir surveillé (DS) confirmé ou évaluation de chapitre.',
|
||||
hex: '#e11d48',
|
||||
chip: 'bg-rose-500/10 border-rose-500/20 text-rose-400',
|
||||
accent: 'text-rose-400',
|
||||
badge: 'bg-rose-500/15 text-rose-300 border-rose-400/30',
|
||||
},
|
||||
tempete: {
|
||||
label: 'Alerte Rouge',
|
||||
title: 'Alerte Rouge / Tempête',
|
||||
icon: '🚨',
|
||||
hint: 'Examen blanc, épreuve commune ou gros devoir de fin de trimestre.',
|
||||
hex: '#dc2626',
|
||||
chip: 'bg-red-500/10 border-red-500/20 text-red-500',
|
||||
accent: 'text-red-500',
|
||||
badge: 'bg-red-500/15 text-red-300 border-red-400/30',
|
||||
},
|
||||
}
|
||||
|
||||
export function isWeatherKind(value: unknown): value is WeatherKind {
|
||||
return typeof value === 'string' && (WEATHER_KINDS as string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Statut par défaut déduit du coefficient, pour pré-remplir le formulaire d'ajout.
|
||||
* 0 = rien de prévu, 1 = interro surprise, 2-3 = DS, 4+ = alerte rouge.
|
||||
*/
|
||||
export function kindFromWeight(weight: number): WeatherKind {
|
||||
if (weight <= 0) return 'soleil'
|
||||
if (weight < 2) return 'vent'
|
||||
if (weight < 4) return 'orage'
|
||||
return 'tempete'
|
||||
}
|
||||
|
||||
/** Les coefficients proposés dans le formulaire, avec leur statut déduit. */
|
||||
export const WEIGHT_PRESETS: { value: number; label: string }[] = [
|
||||
{ value: 0, label: '0 — coursework' },
|
||||
{ value: 1, label: '1 — interro' },
|
||||
{ value: 2, label: '2 — contrôle' },
|
||||
{ value: 3, label: '3 — DS' },
|
||||
{ value: 4, label: '4 — examen blanc' },
|
||||
{ value: 5, label: '5 — épreuve commune' },
|
||||
]
|
||||
|
||||
/** Statut le plus sévère d'une journée ; `soleil` si la journée est vide. */
|
||||
export function dominantKind(exams: { kind: WeatherKind }[]): WeatherKind {
|
||||
let best: WeatherKind = 'soleil'
|
||||
for (const exam of exams) {
|
||||
if (SEVERITY[exam.kind] > SEVERITY[best]) best = exam.kind
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export type Role = 'admin' | 'student'
|
||||
|
||||
export interface Exam {
|
||||
id: number
|
||||
classId: number
|
||||
subject: string
|
||||
teacher: string | null
|
||||
room: string | null
|
||||
chapter: string | null
|
||||
/** ISO 8601 UTC. */
|
||||
startsAt: string
|
||||
durationMin: number
|
||||
/** Coefficient : alimente la future « pression atmosphérique ». */
|
||||
weight: number
|
||||
kind: WeatherKind
|
||||
note: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ClassInfo {
|
||||
id: number
|
||||
name: string
|
||||
level: string | null
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: number
|
||||
classId: number
|
||||
nickname: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
/** Charge utile de `GET /api/me`, renvoyée aussi à la création et à l'adhésion. */
|
||||
export interface Session {
|
||||
member: Member
|
||||
klass: ClassInfo
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
}
|
||||
|
||||
/** Payloads émis par le flux SSE. */
|
||||
export type StreamEvent =
|
||||
| { type: 'exam.created'; exam: Exam }
|
||||
| { type: 'exam.updated'; exam: Exam }
|
||||
| { type: 'exam.deleted'; examId: number }
|
||||
| { type: 'ping' }
|
||||
|
||||
export const CLASS_CODE_LENGTH = 6
|
||||
18
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
101
web/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
web/android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
web/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "fr.meteo.exam"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "fr.meteo.exam"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
19
web/android/app/capacitor.build.gradle
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
web/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
42
web/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package fr.meteo.exam;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
web/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
web/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
web/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
web/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
web/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
web/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
web/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
web/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
web/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
web/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,34 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
170
web/android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
web/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
web/android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
web/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
web/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
web/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
web/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
web/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
web/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
web/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
web/android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Météo Exam</string>
|
||||
<string name="title_activity_main">Météo Exam</string>
|
||||
<string name="package_name">fr.meteo.exam</string>
|
||||
<string name="custom_url_scheme">fr.meteo.exam</string>
|
||||
</resources>
|
||||
22
web/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
web/android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
10
web/android/app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Autorise le HTTP clair vers l'API locale en développement.
|
||||
En production, restreindre à un domaine HTTPS. -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
web/android/build.gradle
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
3
web/android/capacitor.settings.gradle
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||
22
web/android/gradle.properties
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
web/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
web/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
web/android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
web/android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
web/android/settings.gradle
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
web/android/variables.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
17
web/capacitor.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { CapacitorConfig } from '@capacitor/cli'
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'fr.meteo.exam',
|
||||
appName: 'Météo Exam',
|
||||
webDir: 'dist',
|
||||
// L'app Android pointe vers le serveur API distant ; le front est embarqué.
|
||||
// Scheme HTTP : évite le mixed content (page HTTPS → API HTTP).
|
||||
server: {
|
||||
androidScheme: 'http',
|
||||
},
|
||||
android: {
|
||||
allowMixedContent: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
23
web/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html>
|
||||
<html lang="fr" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<title>Météo Exam</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
</head>
|
||||
<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>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
web/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@meteo-exam/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.5.2",
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@meteo-exam/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
17
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
21
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
// Le cookie de session est same-origin : on proxifie l'API plutôt que
|
||||
// d'activer CORS, sinon le cookie ne partirait pas en cross-origin.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787',
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||