Météo Exam v0.1.0 — calendrier des évaluations en bulletin météo

- API Express + SQLite : classes, évaluations, SSE, auth Bearer
- Front Vite + TS + Tailwind : frise 7 jours, Exam Cards, modales
- Capacitor Android : APK debug, CapacitorHttp natif
- Seed : classe Terminale S2 (code TERMS2)
This commit is contained in:
Mathis 2026-10-02 19:51:13 +02:00
commit 6d154ed5fb
92 changed files with 9574 additions and 0 deletions

101
web/android/.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View 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")
}

View 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
View 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

View file

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

View 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>

View file

@ -0,0 +1,5 @@
package fr.meteo.exam;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

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

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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>

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View 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>

View 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>

View 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>

View 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>

View file

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

View 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')

View 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

Binary file not shown.

View 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
View 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
View 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

View 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'

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')

109
web/src/main.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
},
})