diff --git a/.env.example b/.env.example
deleted file mode 100644
index 890f5e1..0000000
--- a/.env.example
+++ /dev/null
@@ -1,45 +0,0 @@
-# ==============================================================
-# SAFE ANESTHESIA - TEMPLATE CONFIGURATION
-# Copier ce fichier vers .env et remplir les valeurs
-# ==============================================================
-
-# 🔐 SÉCURITÉ - JWT et Authentification
-# ⚠️ IMPORTANT: Générer des valeurs FORTES en production!
-# Commande pour générer JWT_SECRET:
-# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
-JWT_SECRET=REMPLACER_PAR_UNE_CLÉ_JWT_LONGUE_ET_ALÉATOIRE
-ADMIN_PASSWORD=REMPLACER_PAR_UN_MOT_DE_PASSE_FORT
-
-# 🗄️ SUPABASE - Base de donnees et stockage
-# Creer un compte gratuit sur https://supabase.com
-# 1. Aller dans Project Settings > API
-# 2. Copier "Project URL" dans SUPABASE_URL
-# 3. Copier "anon public" dans SUPABASE_ANON_KEY
-SUPABASE_URL=https://votre-projet.supabase.co
-SUPABASE_ANON_KEY=votre_cle_anon
-
-# 📧 EMAIL - Configuration SMTP
-# Option A: OVH (email inclus avec le domaine)
-# SMTP_HOST=ssl0.ovh.net
-# SMTP_PORT=587
-# SMTP_SECURE=false
-
-# Option B: Gmail (avec mot de passe d'application)
-# SMTP_HOST=smtp.gmail.com
-# SMTP_PORT=465
-# SMTP_SECURE=true
-
-SMTP_HOST=ssl0.ovh.net
-SMTP_PORT=587
-SMTP_SECURE=false
-SMTP_USER=contact@votre-domaine.fr
-SMTP_PASS=votre_mot_de_passe_email
-CONTACT_EMAIL=contact@votre-domaine.fr
-
-# 🖥️ SERVEUR
-PORT=3000
-NODE_ENV=production # Changer à 'production' en déploiement
-
-# 🌐 URLS DE DÉPLOIEMENT
-VERCEL_URL=https://safe-anesthesia.vercel.app
-RENDER_URL=https://safeanesthesia.onrender.com
diff --git a/README.md b/README.md
deleted file mode 100644
index d60356b..0000000
--- a/README.md
+++ /dev/null
@@ -1,181 +0,0 @@
-# Safe Anesthesia - Formation Medicale Securisee
-
-Plateforme web de **formation médicale continue** pour SPOOA-PM Africa.
-
----
-
-## Deploiement
-
-### Frontend (Vercel)
-
-1. Connecter le repo à Vercel
-2. **Root Directory** = `/frontend`
-3. **Build Command** = *(laisser vide)*
-4. **Output Directory** = `.`
-
-> **Important** : Le dossier `frontend` est configuré comme **Root Directory** dans Vercel. Tous les fichiers statiques (HTML, CSS, JS, images) se trouvent dans ce dossier.
-
-Le frontend sera accessible sur `https://safe-anesthesia.vercel.app`.
-
-### Backend (Render)
-
-1. Connecter le repo à Render
-2. **Root Directory** = `/backend`
-3. **Start Command** = `node server.js`
-
-> **Important** : Le backend reste déployé sur Render avec la commande `node server.js`. Il gère l'API et la base de données.
-
-Le backend sera accessible sur `https://safe-anesthesia.onrender.com`.
-
----
-
-## URL finale (front + API)
-- Frontend (Vercel) : `https://safe-anesthesia.vercel.app`
-- API Backend (Render) : `https://safe-anesthesia.onrender.com/api/...`
-
-
----
-
-## Structure du projet
-
-```
-safeanesthesia/
-├── frontend/ # Site statique (Vercel) — Root Directory
-│ ├── index.html
-│ ├── about.html
-│ ├── contact.html
-│ ├── formations.html
-│ ├── formation.html
-│ ├── admin.html
-│ ├── login.html
-│ ├── vercel.json
-│ ├── css/
-│ │ └── style.css
-│ ├── images/
-│ │ ├── back1.png
-│ │ ├── back3.jpg
-│ │ ├── dg/
-│ │ ├── partenaire/
-│ │ └── spooa/
-│ └── js/ # Scripts frontend
-│ ├── admin.js
-│ ├── formation.js
-│ ├── formations.js
-│ ├── index.js
-│ └── script.js
-│
-└── backend/ # API Node.js + Express (Render)
- ├── server.js
- ├── package.json
- ├── .env.example
- ├── api/
- │ └── handler.js
- ├── data/
- │ └── formations.json
- └── public/
- └── images/
- └── ImageFormation/ # Images uploadées
-```
-
----
-
-## Configuration Backend (.env)
-
-```bash
-cd backend
-cp .env.example .env
-```
-
-Variables requises :
-
-```env
-# Authentification
-ADMIN_PASSWORD=your_admin_password_here
-JWT_SECRET=your_jwt_secret_key_here
-
-# Email SMTP (optionnel)
-SMTP_HOST=smtp.gmail.com
-SMTP_PORT=465
-SMTP_USER=your_email@gmail.com
-SMTP_PASS=your_app_password
-CONTACT_EMAIL=admin@safeanesthesia.com
-
-# Serveur
-PORT=3000
-NODE_ENV=production
-```
-
-**NE JAMAIS COMMITER .env**
-
----
-
-## Acces Admin
-
-**L'accès admin N'est PAS visible sur la page d'accueil !**
-
-Pour y accéder :
-1. Allez à : `https://safe-anesthesia.vercel.app/login.html`
-2. Entrez le mot de passe (dans `.env` → `ADMIN_PASSWORD`)
-3. Cliquez "Se connecter"
-4. Accédez au dashboard `admin.html`
-
----
-
-## Stack technique
-
-**Backend :** Node.js + Express + JWT + JSON DB
-**Frontend :** HTML5 + CSS3 + Vanilla JS (statique)
-**Sécurité :** Helmet + Rate Limiting + Middleware Auth + CORS
-
----
-
-## Routes API
-
-### Publiques
-```
-GET /api/health # Health check
-GET /api/formations # Liste formations
-GET /api/formations/:id # Détail formation
-POST /send # Contact email
-```
-
-### Auth
-```
-POST /login # Connexion (JWT)
-POST /api/auth/login # Connexion (JWT)
-GET /api/auth/verify # Vérifier token
-POST /api/auth/logout # Déconnexion
-```
-
-### Admin (protégées - JWT requis)
-```
-POST /api/admin/formations # Ajouter
-PUT /api/admin/formations/:id # Modifier
-DELETE /api/admin/formations/:id # Supprimer
-```
-
----
-
-## Troubleshooting
-
-**"Le serveur ne démarre pas"**
-```bash
-cd backend
-npm install
-node server.js
-```
-
-**"Impossible de se connecter"**
-- Vérifier que `.env` existe dans `/backend`
-- Vérifier `ADMIN_PASSWORD` défini
-- Vérifier la console serveur pour erreurs
-
-**"CORS blocked"**
-- Vérifier que `https://safe-anesthesia.vercel.app` est dans `allowedOrigins` de `backend/server.js`
-
----
-
-**Version :** 2.0
-**Statut :** Production-Ready
-**Licence :** Propriétaire SPOOA-PM Africa
-
diff --git a/backend/.env.example b/backend/.env.example
deleted file mode 100644
index bf2d785..0000000
--- a/backend/.env.example
+++ /dev/null
@@ -1,26 +0,0 @@
-# Authentification
-ADMIN_PASSWORD=your_admin_password_here
-JWT_SECRET=your_jwt_secret_key_here
-
-# Email SMTP (optionnel)
-SMTP_HOST=smtp.gmail.com
-SMTP_PORT=465
-SMTP_USER=your_email@gmail.com
-SMTP_PASS=your_app_password
-CONTACT_EMAIL=admin@safeanesthesia.com
-
-# Serveur
-PORT=3000
-NODE_ENV=production
-
-# URLs frontend (optionnel, pour CORS dynamique)
-GITHUB_PAGES_URL=
-VERCEL_URL=
-
-# Stockage images (optionnel)
-# Par défaut: "local" (stockage sur Render — attention: filesystem éphémère!)
-# Pour utiliser un stockage externe: "external"
-STORAGE_TYPE=local
-# URL de base si STORAGE_TYPE=external (ex: https://votre-cloudinary.cloudinary.com/images)
-STORAGE_EXTERNAL_URL=
-
diff --git a/backend/server.js b/backend/server.js
index 4231525..2a72c2b 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -12,15 +12,28 @@ import nodemailer from "nodemailer";
dotenv.config();
-if (!process.env.JWT_SECRET || process.env.JWT_SECRET === "your_secret_key") {
- console.error("Erreur: JWT_SECRET n'est pas configuré ou est trop faible. Le serveur s'arrête.");
+if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
+ console.error("Erreur: JWT_SECRET doit contenir au moins 32 caractères. Le serveur s'arrête.");
process.exit(1);
}
const app = express();
const PORT = process.env.PORT || 3000;
-app.use(helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: "cross-origin" } }));
+app.use(helmet({
+ crossOriginResourcePolicy: { policy: "cross-origin" },
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "https://cdnjs.cloudflare.com"],
+ styleSrc: ["'self'", "https://fonts.googleapis.com", "https://cdnjs.cloudflare.com", "'unsafe-inline'"],
+ imgSrc: ["'self'", "https://*.supabase.co", "data:"],
+ fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"],
+ connectSrc: ["'self'", "https://safeanesthesia-api.iragimargos.workers.dev"],
+ upgradeInsecureRequests: [],
+ },
+ },
+}));
app.disable("x-powered-by");
app.use(createCorsMiddleware());
@@ -84,7 +97,7 @@ const transporter = nodemailer.createTransport({
});
app.get("/api/health", (req, res) => {
- res.json({ status: "ok", timestamp: new Date().toISOString() });
+ res.json({ status: "ok" });
});
app.get("/api/formations", async (req, res) => {
@@ -98,12 +111,17 @@ app.get("/api/formations", async (req, res) => {
app.get("/api/formations/:id", async (req, res) => {
try {
- const formation = await getFormation(req.params.id);
+ const id = parseInt(req.params.id);
+ if (isNaN(id) || id < 1) {
+ return res.status(400).json({ error: "ID invalide" });
+ }
+ const formation = await getFormation(id);
if (!formation) {
return res.status(404).json({ error: "Formation introuvable" });
}
res.json(formation);
} catch (error) {
+ console.error(error);
res.status(500).json({ error: "Erreur serveur" });
}
});
@@ -115,9 +133,15 @@ app.post("/api/admin/formations", authAdmin, upload.single("image"), async (req,
if (!titre || !titre.trim()) {
return res.status(400).json({ error: "Titre requis" });
}
+ if (titre.trim().length > 200) {
+ return res.status(400).json({ error: "Le titre ne doit pas dépasser 200 caractères" });
+ }
if (!contenu || !contenu.trim()) {
return res.status(400).json({ error: "Contenu requis" });
}
+ if (contenu.trim().length > 10000) {
+ return res.status(400).json({ error: "Le contenu ne doit pas dépasser 10000 caractères" });
+ }
let imageUrl = null;
if (req.file) {
@@ -133,21 +157,31 @@ app.post("/api/admin/formations", authAdmin, upload.single("image"), async (req,
res.status(201).json({ ok: true, formation: newFormation });
} catch (error) {
console.error(error);
- res.status(500).json({ error: error.message || "Erreur lors de l'ajout de la formation" });
+ const msg = process.env.NODE_ENV === 'production' ? "Erreur lors de l'ajout de la formation" : error.message;
+ res.status(500).json({ error: msg });
}
});
app.put("/api/admin/formations/:id", authAdmin, upload.single("image"), async (req, res) => {
try {
const id = parseInt(req.params.id);
+ if (isNaN(id) || id < 1) {
+ return res.status(400).json({ error: "ID invalide" });
+ }
const { titre, contenu } = req.body;
if (!titre || !titre.trim()) {
return res.status(400).json({ error: "Titre requis" });
}
+ if (titre.trim().length > 200) {
+ return res.status(400).json({ error: "Le titre ne doit pas dépasser 200 caractères" });
+ }
if (!contenu || !contenu.trim()) {
return res.status(400).json({ error: "Contenu requis" });
}
+ if (contenu.trim().length > 10000) {
+ return res.status(400).json({ error: "Le contenu ne doit pas dépasser 10000 caractères" });
+ }
const existing = await getFormation(id);
if (!existing) {
@@ -171,13 +205,17 @@ app.put("/api/admin/formations/:id", authAdmin, upload.single("image"), async (r
res.json({ ok: true, formation: updated });
} catch (error) {
console.error(error);
- res.status(500).json({ error: error.message || "Erreur lors de la mise à jour" });
+ const msg = process.env.NODE_ENV === 'production' ? "Erreur lors de la mise à jour" : error.message;
+ res.status(500).json({ error: msg });
}
});
app.delete("/api/admin/formations/:id", authAdmin, async (req, res) => {
try {
const id = parseInt(req.params.id);
+ if (isNaN(id) || id < 1) {
+ return res.status(400).json({ error: "ID invalide" });
+ }
const formation = await getFormation(id);
if (!formation) {
@@ -192,7 +230,8 @@ app.delete("/api/admin/formations/:id", authAdmin, async (req, res) => {
res.json({ ok: true, message: "Formation supprimée", id });
} catch (error) {
console.error("Erreur DELETE /api/admin/formations/:id:", error.message);
- res.status(500).json({ error: error.message || "Erreur lors de la suppression" });
+ const msg = process.env.NODE_ENV === 'production' ? "Erreur lors de la suppression" : error.message;
+ res.status(500).json({ error: msg });
}
});
@@ -232,6 +271,15 @@ app.post("/send", contactLimiter, async (req, res) => {
if (!name || !email || !message) {
return res.status(400).json({ error: "Tous les champs sont requis" });
}
+ if (name.trim().length > 100) {
+ return res.status(400).json({ error: "Le nom ne doit pas dépasser 100 caractères" });
+ }
+ if (email.trim().length > 200) {
+ return res.status(400).json({ error: "L'email ne doit pas dépasser 200 caractères" });
+ }
+ if (message.trim().length > 5000) {
+ return res.status(400).json({ error: "Le message ne doit pas dépasser 5000 caractères" });
+ }
const safeMessage = message.replace(//g, ">");
@@ -256,13 +304,15 @@ app.post("/send", contactLimiter, async (req, res) => {
res.json({ ok: true, message: "Message reçu! Nous vous répondrons bientôt." });
} catch (error) {
console.error("Erreur POST /send:", error.message);
- res.status(500).json({ error: error.message || "Erreur lors de l'envoi" });
+ const msg = process.env.NODE_ENV === 'production' ? "Erreur lors de l'envoi" : error.message;
+ res.status(500).json({ error: msg });
}
});
app.use((err, req, res, next) => {
console.error("Erreur serveur:", err.message);
- res.status(500).json({ error: err.message || "Erreur serveur interne" });
+ const message = process.env.NODE_ENV === 'production' ? "Erreur serveur interne" : err.message;
+ res.status(500).json({ error: message });
});
app.use((req, res) => {
diff --git a/cloudflare/package-lock.json b/cloudflare/package-lock.json
new file mode 100644
index 0000000..4c94f43
--- /dev/null
+++ b/cloudflare/package-lock.json
@@ -0,0 +1,1552 @@
+{
+ "name": "safeanesthesia-api",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "safeanesthesia-api",
+ "version": "1.0.0",
+ "devDependencies": {
+ "wrangler": "^4.0.0"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260603.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260603.1.tgz",
+ "integrity": "sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260603.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260603.1.tgz",
+ "integrity": "sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260603.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260603.1.tgz",
+ "integrity": "sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260603.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260603.1.tgz",
+ "integrity": "sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260603.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260603.1.tgz",
+ "integrity": "sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
+ "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz",
+ "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "4.20260603.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260603.0.tgz",
+ "integrity": "sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.34.5",
+ "undici": "7.24.8",
+ "workerd": "1.20260603.1",
+ "ws": "8.20.1",
+ "youch": "4.1.0-beta.10"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
+ "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/undici": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz",
+ "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20260603.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260603.1.tgz",
+ "integrity": "sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260603.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260603.1",
+ "@cloudflare/workerd-linux-64": "1.20260603.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260603.1",
+ "@cloudflare/workerd-windows-64": "1.20260603.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.98.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.98.0.tgz",
+ "integrity": "sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.1",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.27.3",
+ "miniflare": "4.20260603.0",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260603.1"
+ },
+ "bin": {
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.20260603.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ }
+ }
+}
diff --git a/cloudflare/package.json b/cloudflare/package.json
new file mode 100644
index 0000000..c7be28f
--- /dev/null
+++ b/cloudflare/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "safeanesthesia-api",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy"
+ },
+ "devDependencies": {
+ "wrangler": "^4.0.0"
+ }
+}
diff --git a/cloudflare/src/index.js b/cloudflare/src/index.js
new file mode 100644
index 0000000..81eb098
--- /dev/null
+++ b/cloudflare/src/index.js
@@ -0,0 +1,466 @@
+// Cloudflare Worker — SafeAnesthesia API
+// Environnements requis (Cloudflare Dashboard > Workers > Variables) :
+// SUPABASE_URL, SUPABASE_ANON_KEY, JWT_SECRET, ADMIN_PASSWORD
+// RESEND_API_KEY (optionnel, pour l'envoi d'email)
+// CONTACT_EMAIL (destination du formulaire de contact)
+
+const ALLOWED_ORIGINS = [
+ "https://safe-anesthesia.vercel.app",
+ "https://safeanesthesia.onrender.com",
+ "http://localhost:3000",
+];
+
+const SECURITY_HEADERS = {
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+};
+
+// requestRef is set at the top of each fetch() call so json() can access it
+let requestRef = null;
+
+function getCorsHeaders() {
+ const origin = requestRef ? requestRef.headers.get("Origin") : null;
+ const cors = {
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+ "Access-Control-Max-Age": "86400",
+ };
+ if (origin && ALLOWED_ORIGINS.includes(origin)) {
+ cors["Access-Control-Allow-Origin"] = origin;
+ } else {
+ cors["Access-Control-Allow-Origin"] = "null";
+ }
+ return cors;
+}
+
+function json(body, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json", ...SECURITY_HEADERS, ...getCorsHeaders() },
+ });
+}
+
+function corsPreflight() {
+ return new Response(null, { status: 204, headers: { ...SECURITY_HEADERS, ...getCorsHeaders() } });
+}
+
+// ─── JWT ─────────────────────────────────────────────────────────────────
+function base64url(buf) {
+ return btoa(String.fromCharCode(...new Uint8Array(buf)))
+ .replace(/=/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+}
+
+function base64urlDecode(str) {
+ str = str.replace(/-/g, "+").replace(/_/g, "/");
+ while (str.length % 4) str += "=";
+ return Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
+}
+
+async function signJwt(payload, secret, expiresInSec = 86400) {
+ const header = { alg: "HS256", typ: "JWT" };
+ const now = Math.floor(Date.now() / 1000);
+ const fullPayload = { ...payload, iat: now, exp: now + expiresInSec };
+ const enc = new TextEncoder();
+ const data = enc.encode(
+ base64url(enc.encode(JSON.stringify(header))) +
+ "." +
+ base64url(enc.encode(JSON.stringify(fullPayload)))
+ );
+ const key = await crypto.subtle.importKey(
+ "raw",
+ enc.encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const sig = await crypto.subtle.sign("HMAC", key, data);
+ return (
+ base64url(enc.encode(JSON.stringify(header))) +
+ "." +
+ base64url(enc.encode(JSON.stringify(fullPayload))) +
+ "." +
+ base64url(sig)
+ );
+}
+
+async function verifyJwt(token, secret) {
+ const parts = token.split(".");
+ if (parts.length !== 3) return null;
+ const enc = new TextEncoder();
+ const data = enc.encode(parts[0] + "." + parts[1]);
+ const key = await crypto.subtle.importKey(
+ "raw",
+ enc.encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["verify"]
+ );
+ const valid = await crypto.subtle.verify("HMAC", key, base64urlDecode(parts[2]), data);
+ if (!valid) return null;
+ const payload = JSON.parse(
+ new TextDecoder().decode(base64urlDecode(parts[1]))
+ );
+ if (payload.exp && Date.now() / 1000 > payload.exp) return null;
+ return payload;
+}
+
+function getToken(request) {
+ const auth = request.headers.get("Authorization");
+ if (!auth || !auth.startsWith("Bearer ")) return null;
+ return auth.slice(7);
+}
+
+async function authGuard(request, env) {
+ const token = getToken(request);
+ if (!token) return json({ message: "Token manquant" }, 401);
+ const payload = await verifyJwt(token, env.JWT_SECRET);
+ if (!payload) return json({ message: "Token invalide ou expiré" }, 403);
+ return payload;
+}
+
+// ─── SUPABASE (via REST) ──────────────────────────────────────────────────
+function supabase(env) {
+ const headers = {
+ "Content-Type": "application/json",
+ apikey: env.SUPABASE_ANON_KEY,
+ Authorization: `Bearer ${env.SUPABASE_ANON_KEY}`,
+ Prefer: "return=representation",
+ };
+ return {
+ async query(method, path, body) {
+ const url = `${env.SUPABASE_URL}${path}`;
+ const opts = { method, headers };
+ if (body) opts.body = JSON.stringify(body);
+ const res = await fetch(url, opts);
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.message || data.error?.message || "Supabase error");
+ return data;
+ },
+ getFormations() {
+ return this.query("GET", "/rest/v1/formations?select=*&order=id.desc");
+ },
+ getFormation(id) {
+ const safeId = encodeURIComponent(String(id));
+ return this.query("GET", `/rest/v1/formations?select=*&id=eq.${safeId}&limit=1`).then(
+ (d) => d[0] || null
+ );
+ },
+ createFormation({ titre, contenu, image }) {
+ return this.query("POST", "/rest/v1/formations", { titre, contenu, image });
+ },
+ updateFormation(id, { titre, contenu, image }) {
+ const safeId = encodeURIComponent(String(id));
+ const updates = { titre, contenu, updatedAt: new Date().toISOString() };
+ if (image !== undefined) updates.image = image;
+ return this.query("PATCH", `/rest/v1/formations?id=eq.${safeId}`, updates);
+ },
+ deleteFormation(id) {
+ const safeId = encodeURIComponent(String(id));
+ return this.query("DELETE", `/rest/v1/formations?id=eq.${safeId}`);
+ },
+ getPublicUrl(path) {
+ return `${env.SUPABASE_URL}/storage/v1/object/public/${path}`;
+ },
+ async uploadImage(fileName, buffer, contentType) {
+ const url = `${env.SUPABASE_URL}/storage/v1/object/formations/${fileName}`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ apikey: env.SUPABASE_ANON_KEY,
+ Authorization: `Bearer ${env.SUPABASE_ANON_KEY}`,
+ "Content-Type": contentType,
+ "x-upsert": "false",
+ },
+ body: buffer,
+ });
+ if (!res.ok) {
+ const err = await res.text();
+ throw new Error(`Upload failed: ${err}`);
+ }
+ return this.getPublicUrl(`formations/${fileName}`);
+ },
+ async deleteImage(imageUrl) {
+ if (!imageUrl) return;
+ const fileName = imageUrl.split("/").pop();
+ const url = `${env.SUPABASE_URL}/storage/v1/object/formations/${fileName}`;
+ await fetch(url, {
+ method: "DELETE",
+ headers: {
+ apikey: env.SUPABASE_ANON_KEY,
+ Authorization: `Bearer ${env.SUPABASE_ANON_KEY}`,
+ },
+ });
+ },
+ };
+}
+
+// ─── EMAIL ─────────────────────────────────────────────────────────────────
+async function sendEmail(env, { name, email, message }) {
+ if (!env.RESEND_API_KEY) return;
+ const safe = (s) =>
+ s.replace(/&/g, "&").replace(//g, ">");
+ const html = `
+
Nouveau message de contact
+ Nom: ${safe(name)}
+ Email: ${safe(email)}
+ Message:
+ ${safe(message).replace(/\n/g, " ")}
+ `;
+ await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${env.RESEND_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: "contact@votre-domaine.com",
+ to: env.CONTACT_EMAIL || env.ADMIN_EMAIL,
+ reply_to: email,
+ subject: `[Contact] ${name}`,
+ html,
+ }),
+ });
+}
+
+const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
+const ALLOWED_IMAGE_EXT = /\.(jpe?g|png|webp)$/i;
+const MAX_FILE_SIZE = 5 * 1024 * 1024;
+
+function validateImageFile(file) {
+ if (!file) return null;
+ if (file.buffer.byteLength > MAX_FILE_SIZE) {
+ return "L'image ne doit pas dépasser 5 Mo.";
+ }
+ if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
+ return "Seules les images (jpg, png, webp) sont autorisées.";
+ }
+ if (!ALLOWED_IMAGE_EXT.test(file.name)) {
+ return "Extension de fichier non autorisée.";
+ }
+ return null;
+}
+
+// ─── FORM DATA PARSER (multipart, fichiers) ───────────────────────────────
+async function parseFormData(request) {
+ const formData = await request.formData();
+ const fields = {};
+ let file = null;
+ for (const [key, value] of formData.entries()) {
+ if (value instanceof File) {
+ file = {
+ name: value.name,
+ type: value.type,
+ buffer: await value.arrayBuffer(),
+ stream: value.stream(),
+ };
+ } else {
+ fields[key] = value;
+ }
+ }
+ return { fields, file };
+}
+
+// ─── RATE LIMITER (simple in-memory) ──────────────────────────────────────
+const rateMap = new Map();
+function rateLimit(key, max = 5, windowMs = 60000) {
+ const now = Date.now();
+ const entry = rateMap.get(key);
+ if (!entry || now - entry.start > windowMs) {
+ rateMap.set(key, { start: now, count: 1 });
+ return true;
+ }
+ if (entry.count >= max) return false;
+ entry.count++;
+ return true;
+}
+
+// ─── ROUTER ────────────────────────────────────────────────────────────────
+export default {
+ async fetch(request, env) {
+ requestRef = request;
+ const url = new URL(request.url);
+ const path = url.pathname;
+ const method = request.method;
+
+ // CORS preflight
+ if (method === "OPTIONS") return corsPreflight();
+
+ try {
+ // ── Health ───────────────────────────────────────
+ if (method === "GET" && path === "/api/health") {
+ return json({ status: "ok" });
+ }
+
+ // ── Formations (public) ──────────────────────────
+ if (method === "GET" && path === "/api/formations") {
+ const db = supabase(env);
+ const formations = await db.getFormations();
+ return json(formations);
+ }
+
+ if (method === "GET" && path.startsWith("/api/formations/")) {
+ const id = path.split("/").pop();
+ if (!/^\d+$/.test(id)) return json({ error: "ID invalide" }, 400);
+ const db = supabase(env);
+ const formation = await db.getFormation(id);
+ if (!formation) return json({ error: "Formation introuvable" }, 404);
+ return json(formation);
+ }
+
+ // ── Admin : Créer une formation ──────────────────
+ if (method === "POST" && path === "/api/admin/formations") {
+ const auth = await authGuard(request, env);
+ if (auth.status) return auth;
+
+ const { fields, file } = await parseFormData(request);
+ if (!fields.titre || !fields.titre.trim())
+ return json({ error: "Titre requis" }, 400);
+ if (fields.titre.trim().length > 200)
+ return json({ error: "Le titre ne doit pas dépasser 200 caractères" }, 400);
+ if (!fields.contenu || !fields.contenu.trim())
+ return json({ error: "Contenu requis" }, 400);
+ if (fields.contenu.trim().length > 10000)
+ return json({ error: "Le contenu ne doit pas dépasser 10000 caractères" }, 400);
+
+ const fileError = validateImageFile(file);
+ if (fileError) return json({ error: fileError }, 400);
+
+ const db = supabase(env);
+ let imageUrl = null;
+ if (file) {
+ const ext = file.name.split(".").pop();
+ const fileName = `${Date.now()}.${ext}`;
+ imageUrl = await db.uploadImage(fileName, file.buffer, file.type);
+ }
+
+ const formation = await db.createFormation({
+ titre: fields.titre.trim(),
+ contenu: fields.contenu.trim(),
+ image: imageUrl,
+ });
+
+ return json({ ok: true, formation }, 201);
+ }
+
+ // ── Admin : Modifier une formation ──────────────
+ if (method === "PUT" && path.startsWith("/api/admin/formations/")) {
+ const auth = await authGuard(request, env);
+ if (auth.status) return auth;
+
+ const id = path.split("/").pop();
+ if (!/^\d+$/.test(id)) return json({ error: "ID invalide" }, 400);
+ const { fields, file } = await parseFormData(request);
+ if (!fields.titre || !fields.titre.trim())
+ return json({ error: "Titre requis" }, 400);
+ if (fields.titre.trim().length > 200)
+ return json({ error: "Le titre ne doit pas dépasser 200 caractères" }, 400);
+ if (!fields.contenu || !fields.contenu.trim())
+ return json({ error: "Contenu requis" }, 400);
+ if (fields.contenu.trim().length > 10000)
+ return json({ error: "Le contenu ne doit pas dépasser 10000 caractères" }, 400);
+
+ const fileError = validateImageFile(file);
+ if (fileError) return json({ error: fileError }, 400);
+
+ const db = supabase(env);
+ const existing = await db.getFormation(id);
+ if (!existing) return json({ error: "Formation introuvable" }, 404);
+
+ let imageUrl = existing.image;
+ if (file) {
+ if (existing.image) await db.deleteImage(existing.image);
+ const ext = file.name.split(".").pop();
+ const fileName = `${Date.now()}.${ext}`;
+ imageUrl = await db.uploadImage(fileName, file.buffer, file.type);
+ }
+
+ await db.updateFormation(id, {
+ titre: fields.titre.trim(),
+ contenu: fields.contenu.trim(),
+ image: imageUrl,
+ });
+
+ return json({ ok: true, message: "Formation mise à jour" });
+ }
+
+ // ── Admin : Supprimer une formation ──────────────
+ if (method === "DELETE" && path.startsWith("/api/admin/formations/")) {
+ const auth = await authGuard(request, env);
+ if (auth.status) return auth;
+
+ const id = path.split("/").pop();
+ if (!/^\d+$/.test(id)) return json({ error: "ID invalide" }, 400);
+ const db = supabase(env);
+ const existing = await db.getFormation(id);
+ if (!existing) return json({ error: "Formation introuvable" }, 404);
+
+ if (existing.image) await db.deleteImage(existing.image);
+ await db.deleteFormation(id);
+
+ return json({ ok: true, message: "Formation supprimée", id: parseInt(id) });
+ }
+
+ // ── Auth : Login ────────────────────────────────
+ if (method === "POST" && path === "/api/auth/login") {
+ const ip = request.headers.get("CF-Connecting-IP") || "unknown";
+ if (!rateLimit(`login:${ip}`, 5, 15 * 60 * 1000)) {
+ return json({ error: "Trop de tentatives, réessayez dans 15 minutes." }, 429);
+ }
+ const { password } = await request.json();
+ if (!password || password !== env.ADMIN_PASSWORD) {
+ return json({ message: "Mot de passe incorrect" }, 401);
+ }
+ const token = await signJwt({ user: "admin" }, env.JWT_SECRET, 86400);
+ return json({ token });
+ }
+
+ // ── Auth : Verify ────────────────────────────────
+ if (method === "GET" && path === "/api/auth/verify") {
+ const auth = await authGuard(request, env);
+ if (auth.status) return auth;
+ return json({ valid: true, user: auth });
+ }
+
+ // ── Auth : Logout ────────────────────────────────
+ if (method === "POST" && path === "/api/auth/logout") {
+ const auth = await authGuard(request, env);
+ if (auth.status) return auth;
+ return json({ message: "Déconnecté avec succès" });
+ }
+
+ // ── Contact ──────────────────────────────────────
+ if (method === "POST" && path === "/send") {
+ const ip = request.headers.get("CF-Connecting-IP") || "unknown";
+ if (!rateLimit(`contact:${ip}`, 5, 60000)) {
+ return json({ error: "Trop de tentatives, réessayez dans 1 minute." }, 429);
+ }
+ const { name, email, message } = await request.json();
+ if (!name || !email || !message) {
+ return json({ error: "Tous les champs sont requis" }, 400);
+ }
+ if (name.trim().length > 100)
+ return json({ error: "Le nom ne doit pas dépasser 100 caractères" }, 400);
+ if (email.trim().length > 200)
+ return json({ error: "L'email ne doit pas dépasser 200 caractères" }, 400);
+ if (message.trim().length > 5000)
+ return json({ error: "Le message ne doit pas dépasser 5000 caractères" }, 400);
+ try {
+ await sendEmail(env, { name, email, message });
+ } catch (e) {
+ console.warn("Email non envoyé:", e.message);
+ }
+ return json({ ok: true, message: "Message reçu! Nous vous répondrons bientôt." });
+ }
+
+ // ── 404 ──────────────────────────────────────────
+ return json({ error: "Route non trouvée" }, 404);
+ } catch (err) {
+ console.error("Erreur:", err.message);
+ return json({ error: err.message || "Erreur serveur interne" }, 500);
+ }
+ },
+};
diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml
new file mode 100644
index 0000000..a379c58
--- /dev/null
+++ b/cloudflare/wrangler.toml
@@ -0,0 +1,6 @@
+name = "safeanesthesia-api"
+main = "src/index.js"
+compatibility_date = "2025-01-01"
+
+[env.production]
+vars = { ENVIRONMENT = "production" }
diff --git a/frontend/about.html b/frontend/about.html
index 4634502..17c2df3 100644
--- a/frontend/about.html
+++ b/frontend/about.html
@@ -9,8 +9,10 @@
+
+
@@ -121,7 +123,7 @@ Création
2021
-
Global SPOOA-PM Africa a été initiée en 2021 par le Dr BARAKA BALOLEBWAMI William , Anesthésiste-réanimateur, Fellow en médecine périopératoire, originaire de la République Démocratique du Congo, actuellement CEO & Founder de Global SPOOA-PM Africa.
+
Global SPOOA-PM Africa a été initiée en 2021 par BARAKA BALOLEBWAMI William , originaire de la République Démocratique du Congo, actuellement CEO & Founder de Global SPOOA-PM Africa.
L'organisation a été fondée à Bujumbura, Burundi , en collaboration avec un réseau panafricain et international de spécialistes en anesthésie, réanimation et médecine de la douleur.
@@ -175,8 +177,8 @@ Positionnement
@@ -185,12 +187,12 @@
Nos Piliers Stratégiques
-
Notre Vision
-
Un continent africain où chaque patient bénéficie d'une anesthésie éthique, sûre et efficace, conforme aux standards internationaux.
+
Ce que nous voulons
+
Un continent où chaque patient , où qu'il soit, reçoit une anesthésie digne et sécurisée — pas seulement sur le papier, mais dans les faits.
- Zéro mortalité évitable liée à l'anesthésie
- Accès équitable aux soins périopératoires
- Excellence clinique sur tout le continent
+ Plus aucune vie perdue à cause d'une anesthésie évitable
+ Des soins périopératoires accessibles à tous, sans exception
+ Une excellence clinique qui soit la norme, pas l'exception
@@ -201,12 +203,12 @@ Notre Vision
-
Notre Mission
-
Développer et diffuser une formation continue de haut niveau , accessible et parfaitement contextualisée aux réalités des ressources limitées.
+
Notre raison d'être
+
Former , connecter , outiller les professionnels de santé africains avec des programmes concrets, adaptés aux réalités du terrain.
- Formations certifiantes et interactives
- Renforcement des capacités locales
- Réseau académique panafricain
+ Des formations qui changent vraiment les pratiques
+ Des compétences locales renforcées, durablement
+ Un réseau qui dépasse les frontières pour apprendre ensemble
@@ -217,13 +219,13 @@ Notre Mission
-
Nos Valeurs
-
Des principes fondamentaux qui guident chacune de nos actions et décisions au service des communautés africaines.
+
Ce qui nous anime
+
Quatre valeurs simples qui nous rappellent pourquoi on se lève chaque matin.
- Sécurité : La protection du patient avant tout
- Excellence : Rigueur scientifique et innovation
- Leadership : Leadership africain et autonomie
- Humanisme : Engagement envers nos communautés
+ Sécurité — le patient d'abord, toujours
+ Excellence — de la rigueur, pas de raccourcis
+ Leadership — l'Afrique porte ses propres solutions
+ Humanisme — parce qu'on soigne des humains, pas des cas
@@ -301,13 +303,23 @@ Pr Afak Nsiri
-
+
-
Professeur Rachida Aouameur
-
Directrice du Département d’Anesthésie Obstétricale & Learning Unit
-
Enseignante à la Faculté de Médecine d’Alger et spécialiste en anesthésie obstétricale à l’EPH Bologhine. Dirige l'unité d'apprentissage.
+
Dr KOULEHO NGAMO JOSEPH
+
Directeur du département d’Anesthésie Obstétricale & Learning Unit
+
Médecin Anesthésiste Reanimateur
+
+Praticien Hospitalier à l'Hôpital Régional de Fatick,
+
+Membre de la SPOO-PM, SCAR, SOSEAR, AFSRA
+
+Centre d'intérêt spécifique : Anesthesie obstétricale, pédiatrique, et Anesthésie locoregional
+
+Dans ce contexte médical qui est le nôtre, nous croyons également à une émergence glorieuse de nôtre Afrique, unie, organisée, qui se focalisera pas sur ce qu'elle pense ne pas avoir mais sur ce qu'elle a présentement, faisant du patient et sa sécurité une priorité majeure.
+
+Never stop learning for life never stops teaching : I never fail I learn
@@ -321,28 +333,28 @@ Pays Membres de Global SPOOA-PM Africa
Une présence panafricaine engagée pour une anesthésie sûre
-
CD - Republique Democratique du Congo
-
BI - Burundi
-
CG - Republique du Congo
-
GA - Gabon
-
TD - Tchad
-
CM - Cameroun
-
CI - Cote d'Ivoire
-
BF - Burkina Faso
-
MG - Madagascar
-
SN - Senegal
-
ML - Mali
-
NE - Niger
-
TG - Togo
-
ZM - Zambie
-
NA - Namibie
-
DZ - Algerie
-
MA - Maroc
-
DJ - Djibouti
-
GW - Guinee-Bissau
-
GN - Guinee (Conakry)
-
CF - Republique Centrafricaine
-
BJ - Benin
+
🇨🇩 Republique Democratique du Congo
+
🇧🇮 Burundi
+
🇨🇬 Republique du Congo
+
🇬🇦 Gabon
+
🇹🇩 Tchad
+
🇨🇲 Cameroun
+
🇨🇮 Cote d'Ivoire
+
🇧🇫 Burkina Faso
+
🇲🇬 Madagascar
+
🇸🇳 Senegal
+
🇲🇱 Mali
+
🇳🇪 Niger
+
🇹🇬 Togo
+
🇿🇲 Zambie
+
🇳🇦 Namibie
+
🇩🇿 Algerie
+
🇲🇦 Maroc
+
🇩🇯 Djibouti
+
🇬🇼 Guinee-Bissau
+
🇬🇳 Guinee (Conakry)
+
🇨🇫 Republique Centrafricaine
+
🇧🇯 Benin
diff --git a/frontend/admin.html b/frontend/admin.html
index e9d02e3..dd5445d 100644
--- a/frontend/admin.html
+++ b/frontend/admin.html
@@ -8,8 +8,10 @@
+
+
diff --git a/frontend/contact.html b/frontend/contact.html
index afeed99..362e8d7 100644
--- a/frontend/contact.html
+++ b/frontend/contact.html
@@ -8,7 +8,11 @@
+
+
+
+
diff --git a/frontend/css/style.css b/frontend/css/style.css
index 862e531..68bf72a 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -155,22 +155,6 @@ img {
will-change: transform;
}
-/* Placeholder fallback */
-.no-image-placeholder {
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--background);
- color: var(--text-light);
- font-size: 1rem;
- height: 180px;
-}
-
-.text-light {
- color: var(--text-light);
- font-weight: 400;
-}
-
/* =========================
Header Global - Design Pro
========================= */
@@ -368,7 +352,21 @@ header {
display: flex;
flex-direction: column;
align-items: center;
+ text-align: center;
gap: var(--spacing-lg);
+ padding: 0;
+ margin: 0;
+ width: 100%;
+}
+.menu ul li {
+ width: 100%;
+ text-align: center;
+}
+.menu ul li a {
+ display: block;
+ width: 100%;
+ padding: 10px 0;
+ font-size: 1.1rem;
}
/* Menu Desktop Query */
@@ -386,9 +384,20 @@ header {
pointer-events: auto;
}
.menu ul {
- flex-direction: column;
flex-direction: row;
gap: var(--spacing-2xl);
+ width: auto;
+ padding: 0;
+ margin: 0;
+ }
+ .menu ul li {
+ width: auto;
+ }
+ .menu ul li a {
+ display: inline;
+ width: auto;
+ padding: 0;
+ font-size: 0.95rem;
}
}
@@ -398,7 +407,7 @@ header {
.hero, .heroAbout, .heroForm, .heroCont {
position: relative;
width: 100%;
- min-height: 80vh; /* Réduit de 150vh pour plus de cohérence */
+ min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
@@ -413,8 +422,8 @@ header {
max-width: none;
}
-.hero { background-image: url("../images/temp/IMG-20260111-WA0103.jpg"); }
-.heroAbout { background-image: url("../images/temp/carousel 2.jpg"); }
+.hero { background-image: url("../images/temp/back7.jpeg"); }
+.heroAbout { background-image: url("../images/temp/back6.jpeg"); }
.heroForm { background-image: url("../images/back3.jpg"); }
.heroCont { background-image: url("../images/spooa/back1.jpg"); }
@@ -687,12 +696,14 @@ p {
}
.card-content h3 {
- font-size: clamp(1.2rem, 2.5vw, 1.5rem);
+ font-size: clamp(1rem, 2vw, 1.2rem);
font-weight: 700;
color: var(--text);
line-height: var(--lh-tight);
transition: color 0.3s ease;
margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
}
.post-card:hover .card-content h3 {
@@ -754,7 +765,7 @@ p {
}
.card-content h3 {
- font-size: 1.2rem;
+ font-size: 1rem;
}
.card-content p {
@@ -765,13 +776,6 @@ p {
/* =========================
Page About - High-End Professional Design
========================= */
-.about-hero-extra {
- max-width: 800px;
- margin-top: 20px;
- font-weight: 300;
- opacity: 0.9;
-}
-
.about-intro-section {
background: white;
padding: 100px 20px;
@@ -837,58 +841,26 @@ p {
margin-bottom: 60px;
}
-.values-card-premium {
- background: white;
- padding: 50px 40px;
- border-radius: 24px;
- box-shadow: var(--shadow-sm);
- transition: var(--transition);
- border: 1px solid var(--border);
- text-align: left;
-}
-
-.values-card-premium:hover {
- transform: translateY(-15px);
- box-shadow: var(--shadow-xl);
- border-color: var(--primary);
-}
-
-.values-card-premium i {
- font-size: 2.5rem;
- color: var(--primary);
- margin-bottom: 25px;
- display: block;
-}
-
-.values-card-premium h3 {
- font-size: 1.4rem;
- font-weight: 800;
- margin-bottom: 1rem;
-}
-
@media (max-width: 992px) {
.about-intro-grid { grid-template-columns: 1fr; gap: 40px; }
.about-text-content h2 { font-size: 2rem; text-align: center; }
.about-text-content h2::after { left: 50%; transform: translateX(-50%); }
}
-.value-card h3 {
- font-size: 1.5rem;
- margin-bottom: 15px;
-}
-
-.value-card:hover {
- transform: translateY(-10px);
- box-shadow: var(--shadow-xl);
- border-color: var(--primary);
-}
-
@media (max-width: 768px) {
- .story-grid, .stats-grid {
- grid-template-columns: 1fr;
- gap: 40px;
+ .stats-banner-inner {
+ flex-direction: column;
+ gap: 30px;
+ padding: 50px 30px;
+ }
+ .stats-banner-divider {
+ width: 80px;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
+ }
+ .stats-banner-number {
+ font-size: 2.8rem;
}
- .stat-number { font-size: 2.5rem; }
}
/* =========================
@@ -940,6 +912,7 @@ p {
width: 100%;
height: 100%;
object-fit: cover;
+ object-position: center 20%;
display: block;
transition: var(--transition);
}
@@ -1010,35 +983,71 @@ p {
/* =========================
Impact Stats Section (Home)
========================= */
-.stats-section {
- background: var(--surface);
- color: var(--text);
- padding: 60px 0;
- margin: 0; /* Collé au Hero */
- border-bottom: 1px solid var(--border);
+/* Stats Banner - Style Compteur */
+.stats-banner {
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
+ padding: 0;
+ margin: 0;
+ max-width: none;
+ position: relative;
+ overflow: hidden;
}
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+.stats-banner::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%2300A86B' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
+ opacity: 0.5;
+}
+
+.stats-banner-inner {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0;
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 70px 40px;
+ position: relative;
+ z-index: 1;
+}
+
+.stats-banner-item {
+ flex: 1;
text-align: center;
- gap: var(--spacing-xl);
+ padding: 0 20px;
}
-.stat-number {
+.stats-banner-number {
display: block;
- font-size: clamp(2.5rem, 8vw, 3.5rem);
+ font-family: var(--font-heading);
+ font-size: clamp(3rem, 8vw, 4.5rem);
font-weight: 800;
- color: var(--primary);
line-height: 1;
+ margin-bottom: 8px;
+ background: linear-gradient(135deg, #00A86B, #34d399);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ letter-spacing: -0.03em;
}
-.stat-label {
- font-family: var(--font-heading);
+.stats-banner-label {
+ display: block;
+ font-family: var(--font-accent);
+ font-size: 0.8rem;
+ font-weight: 400;
+ color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
- letter-spacing: 1px;
- font-size: 0.9rem;
- color: var(--text-light);
+ letter-spacing: 3px;
+}
+
+.stats-banner-divider {
+ width: 1px;
+ height: 80px;
+ background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.15), transparent);
+ flex-shrink: 0;
}
/* CTA Final Home */
@@ -1075,12 +1084,13 @@ p {
border: 1px solid var(--border);
text-align: center;
font-weight: 500;
- font-size: 0.9rem;
+ font-size: 1rem;
color: var(--text-secondary);
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
+ gap: 8px;
min-height: 70px;
}
@@ -1278,7 +1288,47 @@ p {
.search-box {
position: relative;
margin-left: auto;
- min-width: 250px;
+ min-width: 300px;
+}
+
+.search-box input {
+ width: 100%;
+ padding: 14px 48px 14px 20px;
+ border: 2px solid var(--border);
+ background: var(--surface);
+ border-radius: 50px;
+ font-size: 0.95rem;
+ font-family: var(--font-body);
+ color: var(--text);
+ transition: all 0.3s ease;
+ box-shadow: var(--shadow-sm);
+}
+
+.search-box input::placeholder {
+ color: var(--text-light);
+ font-weight: 400;
+}
+
+.search-box input:focus {
+ border-color: var(--primary);
+ background: #fff;
+ outline: none;
+ box-shadow: 0 0 0 4px rgba(0, 168, 107, 0.12), var(--shadow-md);
+}
+
+.search-box i {
+ position: absolute;
+ right: 18px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-light);
+ font-size: 1rem;
+ transition: color 0.3s ease;
+ pointer-events: none;
+}
+
+.search-box:focus-within i {
+ color: var(--primary);
}
@media (max-width: 768px) {
@@ -1300,6 +1350,11 @@ p {
width: 100%;
min-width: auto;
}
+
+ .search-box input {
+ padding: 14px 48px 14px 18px;
+ font-size: 0.9rem;
+ }
}
.search-box input {
@@ -1340,86 +1395,6 @@ p {
margin: 0;
}
-.pedagogie-card {
- background: var(--surface);
- border-radius: 12px;
- padding: var(--spacing-2xl);
- text-align: center;
- box-shadow: var(--shadow-md);
- transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
- border: 2px solid transparent;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: var(--spacing-lg);
- position: relative;
- overflow: hidden;
-}
-
-.pedagogie-card::before {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 4px;
- background: var(--primary);
- transform: scaleX(0);
- transform-origin: center;
- transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
-}
-
-.pedagogie-card:hover {
- box-shadow: var(--shadow-lg);
- transform: translateY(-8px);
- border-color: var(--primary);
-}
-
-.pedagogie-card:hover::before {
- transform: scaleX(1);
-}
-
-.card-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 80px;
- height: 80px;
- background: var(--primary);
- border-radius: 12px;
- box-shadow: 0 4px 15px rgba(0, 168, 107, 0.2);
- transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
-}
-
-.pedagogie-card:hover .card-icon {
- transform: scale(1.1) rotate(5deg);
- box-shadow: 0 8px 25px rgba(0, 168, 107, 0.3);
-}
-
-.card-icon i {
- font-size: 2.2rem;
- color: white;
- transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
-}
-
-.pedagogie-card:hover .card-icon i {
- transform: scale(1.15);
-}
-
-.pedagogie-card h3 {
- color: var(--primary);
- font-size: 1.3rem;
- margin: 0;
- font-weight: 700;
-}
-
-.pedagogie-card p {
- color: var(--text-secondary);
- font-size: 0.95rem;
- line-height: var(--lh-relaxed);
- margin: 0;
-}
-
/* Responsive Pedagogie Section */
@media (max-width: 768px) {
.pedagogie-clean {
@@ -1476,17 +1451,6 @@ p {
/* =========================
Approches Pédagogiques (Refonte)
========================= */
-.pedagogie {
- background: #f8fafc;
- padding: 80px 20px;
- margin-top: 60px;
- border-radius: 30px;
-}
-
-.pedagogie h2 {
- margin-bottom: 50px;
-}
-
.pedagogie-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -3270,9 +3234,8 @@ footer {
Section Piliers Stratégiques
========================= */
.pillars-section {
- background: linear-gradient(135deg, #f8fafc 0%, #f0f9f4 100%);
+ background: #f5f2ee;
padding: var(--spacing-3xl) var(--spacing-lg);
- border-radius: 30px;
margin: var(--spacing-3xl) auto;
}
@@ -3286,99 +3249,76 @@ footer {
gap: var(--spacing-xl);
}
-/* Carte pilier */
.pillar-card {
- background: var(--surface);
- border-radius: 24px;
+ background: #fffcf9;
+ border-radius: 8px;
padding: var(--spacing-2xl);
- box-shadow: var(--shadow-md);
- border: 1px solid var(--border);
- transition: var(--transition);
- position: relative;
- overflow: hidden;
+ border-left: 6px solid var(--primary);
+ transition: box-shadow 0.3s ease;
display: flex;
flex-direction: column;
align-items: flex-start;
}
-.pillar-card::before {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 5px;
- transition: height 0.3s ease;
-}
-
.pillar-card:hover {
- transform: translateY(-8px);
- box-shadow: var(--shadow-xl);
+ box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.08);
}
-.pillar-card:hover::before {
- height: 8px;
+.pillar-card.vision {
+ border-left-color: #2d7d46;
+}
+.pillar-card.mission {
+ border-left-color: #1a5c8a;
+}
+.pillar-card.values {
+ border-left-color: #c0392b;
}
-/* Couleurs spécifiques par pilier */
-.pillar-card.vision::before {
- background: linear-gradient(90deg, var(--secondary), #00c6ff);
+.pillar-card.vision .pillar-icon {
+ background: #2d7d46;
}
-.pillar-card.mission::before {
- background: linear-gradient(90deg, var(--primary), #00c6ff);
+.pillar-card.mission .pillar-icon {
+ background: #1a5c8a;
}
-.pillar-card.values::before {
- background: linear-gradient(90deg, var(--accent), #ff8e8e);
+.pillar-card.values .pillar-icon {
+ background: #c0392b;
}
-/* Icône du pilier */
.pillar-icon {
- width: 70px;
- height: 70px;
- border-radius: 20px;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--spacing-lg);
flex-shrink: 0;
- box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
-}
-
-.pillar-card.vision .pillar-icon {
- background: linear-gradient(135deg, var(--secondary), #00c6ff);
-}
-.pillar-card.mission .pillar-icon {
- background: linear-gradient(135deg, var(--primary), #00c6ff);
-}
-.pillar-card.values .pillar-icon {
- background: linear-gradient(135deg, var(--accent), #ff8e8e);
}
.pillar-icon i {
color: white;
- font-size: 1.8rem;
+ font-size: 1.4rem;
}
-/* Contenu du pilier */
.pillar-content h3 {
- font-size: 1.4rem;
+ font-size: 1.3rem;
font-weight: 700;
- margin-bottom: var(--spacing-md);
+ margin-bottom: var(--spacing-sm);
color: var(--text);
+ letter-spacing: -0.02em;
}
.pillar-lead {
- font-size: 1rem;
- color: var(--text-secondary);
- line-height: var(--lh-relaxed);
- margin-bottom: var(--spacing-lg);
+ font-size: 0.95rem;
+ color: #444;
+ line-height: 1.6;
+ margin-bottom: var(--spacing-md);
}
.pillar-lead strong {
color: var(--text);
}
-/* Liste du pilier */
.pillar-list {
list-style: none;
padding: 0;
@@ -3389,41 +3329,35 @@ footer {
.pillar-list li {
display: flex;
align-items: flex-start;
- gap: 12px;
- padding: 10px 0;
- border-bottom: 1px solid rgba(0, 0, 0, 0.04);
- font-size: 0.92rem;
- color: var(--text-secondary);
- line-height: var(--lh-normal);
-}
-
-.pillar-list li:last-child {
- border-bottom: none;
+ gap: 10px;
+ padding: 8px 0;
+ font-size: 0.9rem;
+ color: #444;
+ line-height: 1.5;
}
.pillar-list li i {
- font-size: 1rem;
- margin-top: 2px;
+ font-size: 0.85rem;
+ margin-top: 4px;
flex-shrink: 0;
- width: 20px;
+ width: 18px;
text-align: center;
+ opacity: 0.7;
}
.pillar-card.vision .pillar-list li i {
- color: var(--secondary);
+ color: #2d7d46;
}
.pillar-card.mission .pillar-list li i {
- color: var(--primary);
+ color: #1a5c8a;
}
.pillar-card.values .pillar-list li i {
- color: var(--accent);
+ color: #c0392b;
}
-/* Responsive Piliers */
@media (max-width: 768px) {
.pillars-section {
padding: var(--spacing-2xl) var(--spacing-md);
- border-radius: 20px;
}
.pillars-grid {
@@ -3436,17 +3370,16 @@ footer {
}
.pillar-icon {
- width: 60px;
- height: 60px;
- border-radius: 16px;
+ width: 48px;
+ height: 48px;
}
.pillar-icon i {
- font-size: 1.5rem;
+ font-size: 1.2rem;
}
.pillar-content h3 {
- font-size: 1.25rem;
+ font-size: 1.15rem;
}
}
diff --git a/frontend/formation.html b/frontend/formation.html
index 834e806..8ef0e4e 100644
--- a/frontend/formation.html
+++ b/frontend/formation.html
@@ -10,6 +10,7 @@
+
diff --git a/frontend/formations.html b/frontend/formations.html
index 5d45bb4..3b699c5 100644
--- a/frontend/formations.html
+++ b/frontend/formations.html
@@ -10,9 +10,11 @@
-
+
+
+
diff --git a/frontend/images/back1.png b/frontend/images/back1.png
deleted file mode 100644
index 83bc9da..0000000
Binary files a/frontend/images/back1.png and /dev/null differ
diff --git a/frontend/images/dg/dg3.jpeg b/frontend/images/dg/dg3.jpeg
new file mode 100644
index 0000000..049b4a5
Binary files /dev/null and b/frontend/images/dg/dg3.jpeg differ
diff --git a/frontend/images/dg/dg3.jpg b/frontend/images/dg/dg3.jpg
deleted file mode 100644
index a62710c..0000000
Binary files a/frontend/images/dg/dg3.jpg and /dev/null differ
diff --git a/frontend/images/placeholder.jpg b/frontend/images/placeholder.jpg
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/images/spooa/back.jpg b/frontend/images/spooa/back.jpg
deleted file mode 100644
index 7a5fc53..0000000
Binary files a/frontend/images/spooa/back.jpg and /dev/null differ
diff --git a/frontend/images/spooa/car2.jpg b/frontend/images/spooa/car2.jpg
index bcc0e8b..354d6b1 100644
Binary files a/frontend/images/spooa/car2.jpg and b/frontend/images/spooa/car2.jpg differ
diff --git a/frontend/images/temp/1763367390728.jpg b/frontend/images/temp/1763367390728.jpg
deleted file mode 100755
index 324dc8d..0000000
Binary files a/frontend/images/temp/1763367390728.jpg and /dev/null differ
diff --git a/frontend/images/temp/IMG-20260111-WA0097.jpg b/frontend/images/temp/IMG-20260111-WA0097.jpg
deleted file mode 100755
index 5bcd0f8..0000000
Binary files a/frontend/images/temp/IMG-20260111-WA0097.jpg and /dev/null differ
diff --git a/frontend/images/temp/IMG-20260111-WA0099.jpg b/frontend/images/temp/IMG-20260111-WA0099.jpg
deleted file mode 100755
index 74b90c9..0000000
Binary files a/frontend/images/temp/IMG-20260111-WA0099.jpg and /dev/null differ
diff --git a/frontend/images/temp/IMG-20260111-WA0103.jpg b/frontend/images/temp/IMG-20260111-WA0103.jpg
deleted file mode 100755
index 3ddcf18..0000000
Binary files a/frontend/images/temp/IMG-20260111-WA0103.jpg and /dev/null differ
diff --git a/frontend/images/temp/IMG-20260111-WA0107.jpg b/frontend/images/temp/IMG-20260111-WA0107.jpg
deleted file mode 100755
index ab67f87..0000000
Binary files a/frontend/images/temp/IMG-20260111-WA0107.jpg and /dev/null differ
diff --git a/frontend/images/temp/back2.jpg b/frontend/images/temp/back2.jpg
deleted file mode 100755
index b270c25..0000000
Binary files a/frontend/images/temp/back2.jpg and /dev/null differ
diff --git a/frontend/images/temp/back6.jpeg b/frontend/images/temp/back6.jpeg
new file mode 100644
index 0000000..16f6784
Binary files /dev/null and b/frontend/images/temp/back6.jpeg differ
diff --git a/frontend/images/temp/back7.jpeg b/frontend/images/temp/back7.jpeg
new file mode 100644
index 0000000..57f8b98
Binary files /dev/null and b/frontend/images/temp/back7.jpeg differ
diff --git a/frontend/images/temp/carousel 1.jpg b/frontend/images/temp/carousel 1.jpg
deleted file mode 100755
index c1958a4..0000000
Binary files a/frontend/images/temp/carousel 1.jpg and /dev/null differ
diff --git a/frontend/images/temp/carousel 2.jpg b/frontend/images/temp/carousel 2.jpg
deleted file mode 100755
index 115f667..0000000
Binary files a/frontend/images/temp/carousel 2.jpg and /dev/null differ
diff --git a/frontend/images/temp/carousel1.jpeg b/frontend/images/temp/carousel1.jpeg
new file mode 100644
index 0000000..354d6b1
Binary files /dev/null and b/frontend/images/temp/carousel1.jpeg differ
diff --git a/frontend/images/temp/carousel3.jpg b/frontend/images/temp/carousel3.jpg
deleted file mode 100755
index f8b154e..0000000
Binary files a/frontend/images/temp/carousel3.jpg and /dev/null differ
diff --git a/frontend/index.html b/frontend/index.html
index 0943c1c..d2d1181 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -11,10 +11,12 @@
+
+
@@ -54,17 +56,28 @@ Global SPOOA-PM Africa
-
Global
-
Global SPOOA-PM Africa
+
GLOBAL SPOOA-PM AFRICA
- Un réseau panafricain de professionnels de santé pour l'amélioration de la sécurité anesthésique
+ GLOBAL SPOOA-PM AFRICA est une plateforme et un réseau panafricain d'excellence dédié à l'anesthésie, à la réanimation, aux soins périopératoires et à la chirurgie globale.
- Global SPOOA-PM Africa est un réseau africain de professionnels de santé engagé dans l'amélioration de la sécurité anesthésique et chirurgicale en Afrique subsaharienne. Il regroupe des professeurs, médecins spécialistes, médecins généralistes, infirmiers, techniciens et autres professionnels de santé.
+ Elle vise l'amélioration des résultats des chirurgies pédiatriques, obstétricales et oncologiques, ainsi que le développement du traitement de la douleur en Afrique, dans une approche intégrée et centrée sur la sécurité du patient.
- L'organisation œuvre à travers la formation, la recherche et le plaidoyer en santé, afin de renforcer les systèmes de soins et les compétences des professionnels de santé.
+ SPOOA-PM signifie Safe Pediatric, Obstetric, Oncologic Anesthesia and Pain Medicine, reflétant un engagement fort pour des soins chirurgicaux sûrs, modernes et adaptés aux réalités africaines.
+
+
+ La plateforme renforce les compétences des professionnels de santé à travers la formation, le mentorat, la simulation clinique et la recherche appliquée.
+
+
+ Elle promeut une prise en charge globale du patient chirurgical, couvrant tout le continuum de soins, de la phase préopératoire à la réanimation et au suivi postopératoire.
+
+
+ SPOOA-PM AFRICA favorise la collaboration entre anesthésistes, réanimateurs, chirurgiens et spécialistes de la douleur pour améliorer la qualité des soins et réduire les complications.
+
+
+ Elle incarne enfin un mouvement panafricain engagé pour transformer durablement les systèmes de santé et garantir des résultats chirurgicaux plus sûrs et plus équitables.
@@ -74,21 +87,21 @@ Global SPOOA-PM Africa
-
-
-
-
- 22+
- Pays Membres
-
-
- 12
- Modules Certifiés
-
-
- 100%
- Impact Panafricain
-
+
+
+
+ 22+
+ Pays Membres
+
+
+
+ 20+
+ Modules Certifiés
+
+
+
+ 100%
+ Impact Panafricain
@@ -96,16 +109,27 @@
Global SPOOA-PM Africa
-
Pourquoi choisir Global SPOOA-PM Africa ?
+
Pourquoi choisir GLOBAL SPOOA-PM AFRICA ?
+
+ En Afrique, chaque bloc opératoire raconte une histoire : celle d'un enfant fragile, d'une mère en urgence, d'un patient atteint de cancer, ou d'une douleur que personne ne devrait supporter seul.
+
+
+ SPOOA-PM AFRICA est né dans cette réalité, là où la chirurgie est à la fois un espoir et un défi, et où la sécurité du patient dépend souvent de la force des équipes et du partage des connaissances.
+
+
+ C'est une plateforme et un réseau qui relient les anesthésistes, réanimateurs, chirurgiens et spécialistes de la douleur autour d'une même mission : rendre la chirurgie plus sûre pour chaque vie confiée à nos soins.
+
+
+ Ici, on ne forme pas seulement des compétences : on construit des réflexes, on affine des gestes, on renforce des équipes face aux urgences pédiatriques, obstétricales et oncologiques.
+
+
+ Dans des contextes parfois limités en ressources, SPOOA-PM AFRICA transforme les contraintes en intelligence collective, et les défis en apprentissages partagés.
+
- Lors de la pandémie Covid-19, nos routines et responsabilités ont été bouleversées.
- Nous avons appris à travailler, étudier et collaborer à distance.
- Avec Global SPOOA-PM Africa, vous vous formez gratuitement, sans vous déplacer et à votre rythme.
- Nos programmes sont certifiés et très instructifs, animés par des experts du monde entier.
+ Chaque formation, chaque échange, chaque expérience devient une graine plantée pour améliorer les résultats chirurgicaux et soulager la douleur là où elle est trop souvent banalisée.
- Vous êtes professionnel de santé ? Vous voulez vous développer, découvrir ce qui se fait ailleurs et acquérir de nouvelles connaissances ?
- Adhérez à Global SPOOA-PM Africa et accédez à notre 12ème module de formation certifiée.
+ Et au cœur de tout cela, une conviction simple : aucun patient en Afrique ne devrait perdre la vie ou souffrir inutilement à cause d'un manque de savoir, de coordination ou de sécurité.
diff --git a/frontend/js/admin.js b/frontend/js/admin.js
index b5a76c4..66f0a0f 100644
--- a/frontend/js/admin.js
+++ b/frontend/js/admin.js
@@ -1,8 +1,9 @@
-let token = localStorage.getItem("token");
+let token = localStorage.getItem("adminToken");
+let currentEditId = null;
-// Toast
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
+ if (!toast) return;
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
toast.className = `toast ${type}`;
toast.innerHTML = `
${message} `;
@@ -11,6 +12,7 @@ function showToast(message, type = 'success') {
}
function boutonLoading(btn, loading) {
+ if (!btn) return;
if (loading) {
btn.disabled = true;
btn.dataset.originalHtml = btn.innerHTML;
@@ -23,27 +25,49 @@ function boutonLoading(btn, loading) {
}
}
-// Initialisation
+function escHTML(str) {
+ if (typeof str !== 'string') return '';
+ return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function escJS(str) {
+ if (typeof str !== 'string') return '';
+ return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
+}
+
+function createSafeButton(text, iconClass, className) {
+ const btn = document.createElement('button');
+ btn.className = className;
+ btn.innerHTML = `
${text}`;
+ return btn;
+}
+
function updateUI() {
+ const loginForm = document.getElementById('loginForm');
+ const dashboard = document.getElementById('dashboardContent');
+ if (!loginForm || !dashboard) return;
if (!token) {
- document.getElementById('loginForm').style.display = 'block';
- document.getElementById('dashboardContent').style.display = 'none';
+ loginForm.style.display = 'block';
+ dashboard.style.display = 'none';
} else {
- document.getElementById('loginForm').style.display = 'none';
- document.getElementById('dashboardContent').style.display = 'flex';
+ loginForm.style.display = 'none';
+ dashboard.style.display = 'flex';
chargerFormations();
}
}
-// Login
document.addEventListener('DOMContentLoaded', () => {
- updateUI();
-
+ try { updateUI(); } catch (e) { console.error(e); }
+
const loginForm = document.querySelector("#login");
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
- const password = document.querySelector("#password").value;
+ const passwordInput = document.querySelector("#password");
+ if (!passwordInput) return;
+ const password = passwordInput.value;
+ const btn = e.target.querySelector('button[type="submit"]');
+ boutonLoading(btn, true);
try {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
@@ -52,16 +76,17 @@ document.addEventListener('DOMContentLoaded', () => {
});
if (!res.ok) throw new Error("Mot de passe incorrect");
const data = await res.json();
+ if (!data.token) throw new Error("Réponse invalide du serveur");
token = data.token;
- localStorage.setItem("token", token);
+ localStorage.setItem("adminToken", token);
location.reload();
} catch (error) {
showToast(`Erreur: ${error.message}`, 'error');
}
+ boutonLoading(btn, false);
});
}
- // Form events
const addForm = document.querySelector("#addForm");
if (addForm) {
addForm.addEventListener("submit", ajouterFormation);
@@ -70,43 +95,124 @@ document.addEventListener('DOMContentLoaded', () => {
const closeBtn = document.getElementById('closeModal');
if (closeBtn) {
closeBtn.onclick = () => {
- document.getElementById('editModal').classList.remove('open');
+ const modal = document.getElementById('editModal');
+ if (modal) modal.classList.remove('open');
currentEditId = null;
};
}
+
+ const editForm = document.querySelector("#editForm");
+ if (editForm) {
+ editForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ if (!currentEditId) return;
+
+ const btn = e.target.querySelector('button[type="submit"]');
+ boutonLoading(btn, true);
+ const formData = new FormData(e.target);
+
+ try {
+ const res = await fetch(`${API_BASE}/api/admin/formations/${currentEditId}`, {
+ method: "PUT",
+ headers: { "Authorization": `Bearer ${token}` },
+ body: formData
+ });
+ if (!res.ok) {
+ let msg = `Erreur ${res.status}`;
+ try { const body = await res.json(); if (body.error) msg += `: ${body.error}`; } catch {}
+ throw new Error(msg);
+ }
+ showToast("Formation mise à jour avec succès !");
+ const modal = document.getElementById('editModal');
+ if (modal) modal.classList.remove('open');
+ currentEditId = null;
+ chargerFormations();
+ } catch (error) {
+ showToast(`Erreur: ${error.message}`, 'error');
+ }
+ boutonLoading(btn, false);
+ });
+ }
});
-// Charger formations
async function chargerFormations() {
try {
const res = await fetch(`${API_BASE}/api/formations`);
if (!res.ok) throw new Error(`Erreur ${res.status}`);
const formations = await res.json();
+ if (!Array.isArray(formations)) throw new Error("Réponse invalide");
+
const container = document.querySelector("#formations");
+ if (!container) return;
container.innerHTML = "";
formations.forEach(f => {
const card = document.createElement("div");
card.className = "admin-card";
- card.innerHTML = `
-
- ${f.image ? `
` : '
'}
-
-
-
-
-
-
-
-
-
- Supprimer
-
-
-
- `;
- card.querySelector(".card-title-text").textContent = f.titre;
- card.querySelector(".card-desc-text").textContent = f.contenu.substring(0, 100) + "...";
+
+ const titreSafe = escJS(f.titre || '');
+ const contenuSafe = escJS(f.contenu || '');
+ const imageUrlStr = f.image ? escJS(f.image) : '';
+
+ const imgDiv = document.createElement('div');
+ imgDiv.className = 'card-image';
+ if (f.image) {
+ const img = document.createElement('img');
+ img.src = imageUrl(f.image);
+ img.alt = f.titre || '';
+ img.loading = 'lazy';
+ imgDiv.appendChild(img);
+ } else {
+ imgDiv.innerHTML = '
';
+ }
+ card.appendChild(imgDiv);
+
+ const contentDiv = document.createElement('div');
+ contentDiv.className = 'admin-card-content';
+
+ const h3 = document.createElement('h3');
+ h3.className = 'card-title-text';
+ h3.textContent = f.titre || '';
+ contentDiv.appendChild(h3);
+
+ const p = document.createElement('p');
+ p.className = 'card-desc-text';
+ p.textContent = (f.contenu || '').substring(0, 100) + '...';
+ contentDiv.appendChild(p);
+
+ const actionsDiv = document.createElement('div');
+ actionsDiv.className = 'admin-actions';
+
+ const editBtn = createSafeButton('', 'fa-pen', 'action-btn btn-edit');
+ editBtn.addEventListener('click', () => {
+ currentEditId = f.id;
+ const editTitre = document.getElementById('editTitre');
+ const editContenu = document.getElementById('editContenu');
+ const currentImageDiv = document.getElementById('currentImage');
+ const modal = document.getElementById('editModal');
+
+ if (editTitre) editTitre.value = f.titre || '';
+ if (editContenu) editContenu.value = f.contenu || '';
+
+ if (currentImageDiv) {
+ if (f.image) {
+ currentImageDiv.innerHTML = `
Image actuelle
`;
+ } else {
+ currentImageDiv.innerHTML = '
Aucune image actuelle
';
+ }
+ }
+
+ if (modal) modal.classList.add('open');
+ });
+ actionsDiv.appendChild(editBtn);
+
+ const deleteBtn = createSafeButton(' Supprimer', 'fa-trash', 'action-btn btn-delete');
+ deleteBtn.addEventListener('click', () => supprimerFormation(f.id));
+ actionsDiv.appendChild(deleteBtn);
+
+ contentDiv.appendChild(actionsDiv);
+ card.appendChild(contentDiv);
+
container.appendChild(card);
});
} catch (error) {
@@ -114,19 +220,16 @@ async function chargerFormations() {
}
}
-// Ajouter formation
async function ajouterFormation(e) {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
boutonLoading(btn, true);
const formData = new FormData(e.target);
-
+
try {
const res = await fetch(`${API_BASE}/api/admin/formations`, {
method: "POST",
- headers: {
- "Authorization": `Bearer ${token}`
- },
+ headers: { "Authorization": `Bearer ${token}` },
body: formData
});
if (!res.ok) {
@@ -134,8 +237,8 @@ async function ajouterFormation(e) {
try { const body = await res.json(); if (body.error) msg += `: ${body.error}`; } catch {}
throw new Error(msg);
}
- showToast("Formation ajoutee avec succes !");
- e.target.reset();
+ showToast("Formation ajoutée avec succès !");
+ try { e.target.reset(); } catch {}
chargerFormations();
} catch (error) {
showToast(`Erreur: ${error.message}`, 'error');
@@ -143,73 +246,27 @@ async function ajouterFormation(e) {
boutonLoading(btn, false);
}
-// Supprimer formation
async function supprimerFormation(id) {
- if (confirm("etes-vous sur de vouloir supprimer cette formation ?")) {
- try {
- const res = await fetch(`${API_BASE}/api/admin/formations/${id}`, {
- method: "DELETE",
- headers: { "Authorization": `Bearer ${token}` }
- });
- if (!res.ok) {
- let msg = `Erreur ${res.status}`;
- try { const body = await res.json(); if (body.error) msg += `: ${body.error}`; } catch {}
- throw new Error(msg);
- }
- showToast("Formation supprimee avec succes !");
- chargerFormations();
- } catch (error) {
- showToast(`Erreur: ${error.message}`, 'error');
+ if (!confirm("Êtes-vous sûr de vouloir supprimer cette formation ?")) return;
+ try {
+ const res = await fetch(`${API_BASE}/api/admin/formations/${id}`, {
+ method: "DELETE",
+ headers: { "Authorization": `Bearer ${token}` }
+ });
+ if (!res.ok) {
+ let msg = `Erreur ${res.status}`;
+ try { const body = await res.json(); if (body.error) msg += `: ${body.error}`; } catch {}
+ throw new Error(msg);
}
+ showToast("Formation supprimée avec succès !");
+ chargerFormations();
+ } catch (error) {
+ showToast(`Erreur: ${error.message}`, 'error');
}
}
-let currentEditId = null;
-
-window.prepareEdit = function(id, titre, contenu, image) {
- currentEditId = id;
- document.getElementById('editTitre').value = titre;
- document.getElementById('editContenu').value = contenu;
- const currentImageDiv = document.getElementById('currentImage');
- if (image) {
- currentImageDiv.innerHTML = `
Image actuelle
`;
- } else {
- currentImageDiv.innerHTML = '
Aucune image actuelle
';
- }
- document.getElementById('editModal').classList.add('open');
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- const editForm = document.querySelector("#editForm");
- if (editForm) {
- editForm.addEventListener("submit", async (e) => {
- e.preventDefault();
- if (!currentEditId) return;
-
- const btn = e.target.querySelector('button[type="submit"]');
- boutonLoading(btn, true);
- const formData = new FormData(e.target);
-
- try {
- const res = await fetch(`${API_BASE}/api/admin/formations/${currentEditId}`, {
- method: "PUT",
- headers: {
- "Authorization": `Bearer ${token}`
- },
- body: formData
- });
- if (!res.ok) {
- let msg = `Erreur ${res.status}`;
- try { const body = await res.json(); if (body.error) msg += `: ${body.error}`; } catch {}
- throw new Error(msg);
- }
- showToast("Formation mise a jour avec succes !");
- document.getElementById('editModal').classList.remove('open');
- chargerFormations();
- } catch (error) {
- showToast(`Erreur: ${error.message}`, 'error');
- }
- boutonLoading(btn, false);
- });
- }
-});
+window.addEventListener('unhandledrejection', (e) => {
+ console.error('Erreur non gérée:', e.reason);
+ showToast('Une erreur inattendue est survenue', 'error');
+ e.preventDefault();
+});
\ No newline at end of file
diff --git a/frontend/js/config.js b/frontend/js/config.js
index 31df7cd..4ae3c98 100644
--- a/frontend/js/config.js
+++ b/frontend/js/config.js
@@ -1,4 +1,4 @@
-const API_BASE = "https://safeanesthesia.onrender.com";
+const API_BASE = "https://safeanesthesia-api.iragimargos.workers.dev";
function imageUrl(path) {
if (!path) return null;
diff --git a/frontend/js/formations.js b/frontend/js/formations.js
index f582ffb..4147f74 100644
--- a/frontend/js/formations.js
+++ b/frontend/js/formations.js
@@ -1,52 +1,74 @@
-async function chargerFormations() {
- try {
- const res = await fetch(`${API_BASE}/api/formations`);
- if (!res.ok) throw new Error("Impossible de charger les formations");
- const formations = await res.json();
+let toutesLesFormations = [];
- const container = document.querySelector("#formations");
- container.innerHTML = "";
-
- formations.forEach(f => {
- const card = document.createElement("div");
- card.className = "post-card";
- card.setAttribute("data-id", f.id);
-
- const imageDiv = document.createElement("div");
- imageDiv.className = "card-image";
-
- if (f.image) {
- const img = document.createElement("img");
- img.src = imageUrl(f.image);
- img.alt = f.titre;
- img.loading = "lazy";
- img.decoding = "async";
- img.style.display = "block";
- img.onerror = function() {
- this.outerHTML = '
';
- };
- imageDiv.appendChild(img);
- } else {
- imageDiv.innerHTML = '
';
- }
+function creerCarte(f) {
+ const card = document.createElement("div");
+ card.className = "post-card";
+ card.setAttribute("data-id", f.id);
- const contentDiv = document.createElement("div");
- contentDiv.className = "card-content";
- contentDiv.innerHTML = `
-
${f.titre}
-
${f.contenu.substring(0, 120)}...
-
Découvrir le programme
- `;
+ const imageDiv = document.createElement("div");
+ imageDiv.className = "card-image";
- card.appendChild(imageDiv);
- card.appendChild(contentDiv);
+ if (f.image) {
+ const img = document.createElement("img");
+ img.src = imageUrl(f.image);
+ img.alt = f.titre;
+ img.loading = "lazy";
+ img.decoding = "async";
+ img.style.display = "block";
+ img.onerror = function() {
+ this.outerHTML = '
';
+ };
+ imageDiv.appendChild(img);
+ } else {
+ imageDiv.innerHTML = '
';
+ }
- card.addEventListener("click", () => {
- window.location.href = `formation.html?id=${f.id}`;
- });
+ const contentDiv = document.createElement("div");
+ contentDiv.className = "card-content";
- container.appendChild(card);
- });
+ const h3 = document.createElement("h3");
+ h3.textContent = f.titre || '';
+ contentDiv.appendChild(h3);
+
+ const p = document.createElement("p");
+ p.textContent = (f.contenu || '').substring(0, 120) + '...';
+ contentDiv.appendChild(p);
+
+ const span = document.createElement("span");
+ span.className = "read-more";
+ span.textContent = "Découvrir le programme";
+ contentDiv.appendChild(span);
+
+ card.appendChild(imageDiv);
+ card.appendChild(contentDiv);
+
+ card.addEventListener("click", () => {
+ window.location.href = `formation.html?id=${f.id}`;
+ });
+
+ return card;
+}
+
+function afficherFormations(formations) {
+ const container = document.querySelector("#formations");
+ container.innerHTML = "";
+
+ if (formations.length === 0) {
+ container.innerHTML = '
Aucune formation ne correspond à votre recherche.
';
+ return;
+ }
+
+ formations.forEach(f => {
+ container.appendChild(creerCarte(f));
+ });
+}
+
+async function chargerFormations() {
+ try {
+ const res = await fetch(`${API_BASE}/api/formations`);
+ if (!res.ok) throw new Error("Impossible de charger les formations");
+ toutesLesFormations = await res.json();
+ afficherFormations(toutesLesFormations);
} catch (err) {
console.error("Erreur chargement formations:", err);
const container = document.querySelector("#formations");
@@ -54,4 +76,22 @@ async function chargerFormations() {
}
}
-document.addEventListener("DOMContentLoaded", chargerFormations);
+document.addEventListener("DOMContentLoaded", () => {
+ chargerFormations();
+
+ const searchInput = document.getElementById("formationSearch");
+ if (searchInput) {
+ searchInput.addEventListener("input", (e) => {
+ const terme = e.target.value.toLowerCase().trim();
+ if (!terme) {
+ afficherFormations(toutesLesFormations);
+ return;
+ }
+ const resultats = toutesLesFormations.filter(f => {
+ const titre = (f.titre || "").toLowerCase();
+ return titre.includes(terme);
+ });
+ afficherFormations(resultats);
+ });
+ }
+});
diff --git a/frontend/js/index.js b/frontend/js/index.js
index 78eb9fd..56da769 100644
--- a/frontend/js/index.js
+++ b/frontend/js/index.js
@@ -1,6 +1,12 @@
let tutesLesFormations = [];
let modeRecherche = false;
+const STATIQUES = [
+ "Compétences non techniques en santé",
+ "Urgences et réanimation de base",
+ "Urgences et réanimation obstétricales"
+];
+
function afficherFormations(formations) {
const container = document.querySelector("#formations");
if (!container) return;
@@ -11,7 +17,19 @@ function afficherFormations(formations) {
}
container.innerHTML = "";
- const afficher = modeRecherche ? formations : formations.slice(0, 3);
+ let afficher;
+ if (modeRecherche) {
+ afficher = formations;
+ } else {
+ const statiques = tutesLesFormations.filter(f =>
+ STATIQUES.includes(f.titre)
+ );
+ if (statiques.length === 3) {
+ afficher = statiques;
+ } else {
+ afficher = formations.slice(0, 3);
+ }
+ }
afficher.forEach(f => {
const card = document.createElement("div");
@@ -85,8 +103,7 @@ document.addEventListener("DOMContentLoaded", () => {
const terme = e.target.value.toLowerCase();
const resultats = (tutesLesFormations || []).filter(f => {
const titre = (f.titre || "").toLowerCase();
- const contenu = (f.contenu || "").toLowerCase();
- return titre.includes(terme) || contenu.includes(terme);
+ return titre.includes(terme);
});
afficherFormations(resultats);
});
diff --git a/frontend/login.html b/frontend/login.html
index 2852862..b93141f 100644
--- a/frontend/login.html
+++ b/frontend/login.html
@@ -172,6 +172,7 @@
font-weight: 600;
}
+
diff --git a/frontend/sitemap.xml b/frontend/sitemap.xml
new file mode 100644
index 0000000..6541153
--- /dev/null
+++ b/frontend/sitemap.xml
@@ -0,0 +1,19 @@
+
+
+
+ https://spooapmafrica.com/
+ 1.0
+
+
+ https://spooapmafrica.com/about
+ 0.8
+
+
+ https://spooapmafrica.com/formations
+ 0.9
+
+
+ https://spooapmafrica.com/contact
+ 0.7
+
+