Compare commits

...

45 Commits

Author SHA1 Message Date
gilles 5cfd8184e9 Ajouter data/uploads/test
Build & Push OCI / build (./backend, backend) (push) Failing after 19s
Build & Push OCI / build (./frontend, frontend) (push) Failing after 16s
2026-05-31 09:43:20 +02:00
gilles 59b5836fbd Actualiser .env.example
Build & Push OCI / build (./backend, backend) (push) Failing after 15s
Build & Push OCI / build (./frontend, frontend) (push) Failing after 15s
2026-05-30 11:05:48 +02:00
gilles 091eead5bb fix(pwa): manifest cassé + icônes PWA non appliquées
Build & Push OCI / build (./backend, backend) (push) Failing after 2m59s
Build & Push OCI / build (./frontend, frontend) (push) Failing after 16s
index.html référençait /manifest.json (inexistant) → nginx renvoyait index.html
en fallback SPA → "Manifest: Line 1, column 1, Syntax error" dans Chromium, et la
PWA n'avait aucune icône valide (d'où les favicons icon-192/512 non utilisées
pour le raccourci).

- Retire le <link rel="manifest" href="/manifest.json"> codé en dur :
  VitePWA injecte déjà /manifest.webmanifest au build
- Ajoute <link rel="apple-touch-icon" href="/icons/icon-192.png"> pour le
  raccourci écran d'accueil iOS (qui n'utilise pas le manifest pour l'icône)

v0.5.17

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:53:51 +02:00
gilles 36b5760566 chore(deploy): images OCI Gitea + compose production + CI
Recrée les fichiers de déploiement perdus lors de la restauration du repo.

- docker-compose.deploy.yml : production basée sur les images publiées
  (git.maison43gil.com/gilles/home_hub:{backend,frontend}-latest), sans build:,
  avec service backend-migrate (alembic upgrade head) avant le démarrage
- .gitea/workflows/build.yml : CI Gitea Actions, build+push des 2 images
- backend/.dockerignore + frontend/.dockerignore : images propres, sans secrets
- .env.example : template complet sans secret réel (placeholder change-me)
- README : section déploiement OCI (build manuel, CI, serveur, note 413 proxy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:45:30 +02:00
gilles c36be15e18 OCI registry consigne 2026-05-30 10:16:16 +02:00
gilles 53018c16dd fix(notes): erreur 500 sur les notes antérieures (urls=NULL)
Les notes créées avant la migration 0061 ont urls=NULL en base. Le défaut
NoteResponse.urls=[] ne s'applique qu'à un attribut absent, pas à None, d'où
ResponseValidationError "Input should be a valid list" → GET /api/notes 500
→ "Erreur de chargement" côté UI.

Ajoute un field_validator(mode='before') qui coerce None → [].
Nettoie aussi l'import HttpUrl inutilisé.

v0.5.16

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:15:15 +02:00
gilles 6c889f1561 fix(mcp+alembic): désactive DNS rebinding (421) + rechaîne migrations 006
MCP :
- FastMCP recevait Host=localhost (sans port) mais le pattern par défaut
  allowed_hosts=["localhost:*", ...] EXIGE un port → 421 Invalid Host header
  pour tout accès non-localhost (ex: Hermes via http://10.0.0.50:3001/mcp)
- Désactive enable_dns_rebinding_protection : le Bearer MCP_API_KEY est la
  vraie barrière (protection rebinding = anti-attaque navigateur, inutile ici)
- nginx /mcp : retour à Host $host (le rewrite localhost était cassé)

Alembic :
- Collision : 006_notes_urls et 006_product_tags partageaient revision='006'
  → "Multiple head revisions" au démarrage
- Renumérote notes_urls en 0061, chaîné après product_tags
  Chaîne finale : 005 -> 006 (product_tags) -> 0061 (notes_urls) -> 007

v0.5.15

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:07:00 +02:00
gilles 7bf6caa3dd feat(shopping): listes projet + déduplication nommage hebdo
Backend :
- Migration 007 : list_type VARCHAR(20) sur shopping.lists (weekly/project),
  url/description/image_url sur shopping.list_items
- Modèle ShoppingList : champ list_type
- Modèle ListItem : champs url, description, image_url
- Schémas : list_type sur Create/Response, nouveaux champs sur ItemCreate/Update/Response
- _unique_week_label() : évite les doublons S22 2026 → S22 2026 (2)
- finish_shopping : carry-over uniquement pour list_type='weekly'

Frontend :
- api/shopping.ts : list_type, champs enrichis item, createProjectList()
- ProjectItemCard.tsx : carte avec image, description, URL, boutique, cochage
- ShoppingPage :
  · Séparation weekly / project dans la sélection de liste active
  · Section "Listes projet" sur l'écran vide avec navigation
  · Badge PROJET dans l'en-tête
  · Bouton "Clôturer la semaine" et badge "semaine dépassée" masqués sur projet
  · Bouton "+ Ajouter" (mobile + laptop) sur les listes projet
  · Vue grille ProjectItemCard pour les listes projet
  · Modale création liste projet (nom + boutique)
  · Modale ajout/édition item projet (nom, description, URL, image URL)

v0.5.14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:59:53 +02:00
gilles 031708ad8f feat(notes): ajout de liens nommés (label + url) sur les notes
Backend :
- Migration 006 : colonne urls JSONB nullable sur notes.items
- Modèle NoteItem : champ urls list[dict]
- Schémas : NoteUrl (label + url avec validation http/https),
  NoteCreate/NoteUpdate/NoteResponse exposent urls

Frontend :
- api/notes.ts : interface NoteUrl + champ urls sur Note/NoteCreate
- NoteForm : section "Liens" avec ajout (libellé + URL), suppression,
  validation http/https, confirmation par Enter
- NotesPage : badge compteur liens dans metaLine (semi/collapsed),
  section liens cliquables dans le mode expanded

v0.5.13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:47:49 +02:00
gilles b084905226 fix(ui): icône logo TopBar → fa-circle-nodes (cohérence avec icon hub)
Remplace fa-house par fa-circle-nodes dans la TopBar pour aligner
le logo affiché dans l'app avec la nouvelle icône PWA/favicon hub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:42:49 +02:00
gilles 208af72344 6 2026-05-30 09:33:12 +02:00
gilles 7a500e2435 chore(design): nouvelle icône hub (réseau de nœuds) favicon + PWA
Remplace la maison par un graphe central orange (accent) avec 3 nœuds
satellites crème (d5c4a1) sur fond sombre Gruvbox. Décliné en
favicon.svg, icon-192.png et icon-512.png (PWA shortcut smartphone).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 09:29:28 +02:00
gilles 4518ed8311 chore(design): ajout du package design system smartphone
Contient les tokens, composants et exemples adaptés au mobile,
à utiliser comme référence lors du développement des vues smartphone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:53:36 +02:00
gilles 4c616fa5d3 fix(ui): bouton + laptop, overflow mobile, clôture semaine, backup complet
- TodosPage/ShoppingPage : bouton « + » visible en laptop (hidden lg:flex)
- ShoppingPage : renomme « Terminer » en « Clôturer la semaine », badge ⚠
  si semaine ISO dépassée, confirmation modale avec décompte non-cochés
- NotesPage : overflowWrap:anywhere sur titre/contenu/markdown (URLs longues
  qui débordaient hors de la tuile sur smartphone)
- index.css : overflow-x:hidden + max-width:100vw sur html/body (garde-fou global)
- admin.py : backup remplacé par archive .tar.gz (pg_dump + uploads/) streamée
  au navigateur ; restore via multipart upload avec extraction sécurisée
- admin.ts : downloadBackup() (blob trigger) + uploadAndRestore() avec progression XHR
- ConfigPage : refonte section backup avec boutons Télécharger/Restaurer
  et barre de progression upload

v0.5.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:52:37 +02:00
gilles 69c2042995 fix(mcp): nginx Host=localhost pour passer la protection DNS rebinding FastMCP 2026-05-28 10:42:57 +02:00
gilles 20483dc5f9 fix(mcp): désactiver DNS rebinding protection (auth Bearer suffisant) 2026-05-28 10:40:44 +02:00
gilles 727ebc6484 fix(mcp): démarrer session_manager dans le lifespan FastAPI 2026-05-28 10:31:34 +02:00
gilles 39939b9621 fix(mcp): streamable_http_path=/ pour mount FastAPI à /mcp 2026-05-28 10:05:52 +02:00
gilles d50d659daf fix: autoriser 10.0.1.45 dans CORS_ORIGINS 2026-05-28 06:58:00 +02:00
gilles 0a798d2791 chore: bump version — MCP server v0.5.10 2026-05-28 06:54:46 +02:00
gilles 828efb9dd8 fix(mcp): MCP_API_KEY via variable d'environnement (pas en clair dans docker-compose) 2026-05-28 06:53:36 +02:00
gilles 8ebdccb543 feat(mcp): câblage FastAPI + nginx proxy + docker-compose MCP_API_KEY
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:49:50 +02:00
gilles 5d7dbec67c fix(mcp): status active + search_products guard + item.product + cleanup auto-name 2026-05-28 06:41:04 +02:00
gilles 87efbcb03d feat(mcp): 6 outils shopping + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:37:38 +02:00
gilles c72ffd0ad6 fix(mcp): FTS colonnes non qualifiées + test positif search_notes 2026-05-28 06:29:36 +02:00
gilles e902452781 feat(mcp): 5 outils notes + tests
Ajoute search_notes, get_note, create_note, update_note, delete_note au serveur MCP.
Tests: 6 nouveaux tests notes (13 tests MCP au total, tous passent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:11:15 +02:00
gilles 6cd866c77a fix(mcp): scope fixture NullPool + suppression imports inutiles + validation enums + cleanup tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:03:43 +02:00
gilles 05db49f27a feat(mcp): 5 outils todos + tests
Ajoute mcp_server.py avec get_todos, create_todo, update_todo, postpone_todo, delete_todo.
Ajoute test_mcp.py (7 tests). Corrige conftest pour injecter NullPool dans AsyncSessionLocal des outils MCP (évite les conflits d'event loop entre tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:58:20 +02:00
gilles 24598c836b fix(mcp): comparaison constante hmac + rejet si clé vide + WWW-Authenticate
Fixes deux vulnérabilités critiques en sécurité:

1. **Timing attack** — remplace la comparaison naïve `!=` par
   `hmac.compare_digest()` pour éviter les attaques temporelles
   (constant-time comparison).

2. **Clé vide acceptée** — ajoute le check `not settings.mcp_api_key`
   pour rejeter (401) TOUS les requêtes `/mcp` si MCP_API_KEY n'est
   pas configurée, empêchant l'accès unauthenticated silencieux.

Bonus: ajoute l'en-tête `www-authenticate: Bearer` (RFC 9110).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:49:45 +02:00
gilles cc8fc5ba3f feat(mcp): middleware ASGI Bearer token pour /mcp* 2026-05-25 22:47:24 +02:00
gilles 6ff7c2f74e fix(mcp): contrainte version mcp<2.0 + MCP_API_KEY dans .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:46:36 +02:00
gilles 48e1b5343e feat(mcp): dépendance mcp>=1.9 + champ mcp_api_key dans Settings 2026-05-25 22:44:57 +02:00
gilles bbf264fb61 docs: plan d'implémentation MCP server (16 outils + tutoriel Hermes) 2026-05-25 22:40:41 +02:00
gilles b8d89acafa docs: ajout clé MCP_API_KEY générée dans la spec 2026-05-25 22:29:54 +02:00
gilles fefde4eb31 docs: spec serveur MCP HomeHub (16 outils, Streamable HTTP, Hermes) 2026-05-25 22:28:28 +02:00
gilles 273e032245 3 2026-05-25 21:27:13 +02:00
gilles f81be12a38 feat(notes): renderer markdown étendu v0.5.9
- Tableaux (pipe syntax) avec header distinct
- Task lists - [ ] / - [x] avec checkbox colorée
- Listes imbriquées avec indentation (• / ◦)
- Texte barré ~~strikethrough~~
- Liens [texte](url) cliquables (target _blank)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:13:14 +02:00
gilles ec87bc091d feat(sse): sync temps réel multi-appareils via Server-Sent Events v0.5.8
- Broadcaster asyncio.Queue avec keepalive 25s (prévient timeout proxy)
- Endpoint GET /api/events/stream (StreamingResponse text/event-stream)
- Broadcast notes_changed / todos_changed / shopping_changed sur toutes mutations
- Hook useServerEvents: EventSource avec reconnexion automatique (3s)
- Pages Notes, Todos, Shopping abonnées aux événements SSE
- nginx: location SSE dédiée (proxy_buffering off, timeout 24h)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:12:02 +02:00
gilles 2129da4f55 fix(notes): audio overflow + volume 50%, grille 3col, bouton laptop, sidebar BDD
- Audio : minWidth:0 + onLoadedMetadata volume=0.5 (plus de débordement)
- Grille : repeat(3,1fr) sur laptop, 1fr sur mobile (était auto-fill)
- Header laptop : bouton "Nouvelle note" (fa-plus + accent) visible lg:flex
- SideNav : DbStatusBar en bas — LED verte/rouge + taille BDD, polling 30s
- docs/plan.md : Phase 4c documentée

v0.5.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:51:44 +02:00
gilles c72ca04fd2 feat(config): stats vidéo + user 1000:1000 dans docker-compose
Admin stats : ajout video (count + size_bytes) dans /api/admin/stats.
ConfigPage : grille médias 3 colonnes (Photos / Audio / Vidéos).

docker-compose : backend et backend-worker tournent en user 1000:1000
pour que les fichiers écrits dans ./data/ appartiennent à l'utilisateur
hôte et non à root.

v0.5.6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:44:31 +02:00
gilles dd4ce6f52b feat(notes): 3 états de tuile + renderer pseudo-markdown
Tuile note : semi (défaut, 3 lignes tronquées) / expanded (markdown complet
+ médias) / collapsed (titre + date uniquement). Bouton toggle fa-chevron
en haut à droite qui cycle entre les états.

Renderer pseudo-markdown inline : # ## ###, - * listes, 1. numérotées,
> citations, --- séparateur, **gras** *italique* `code`, ``` blocs.

Méta de tuile : icônes fa-image / fa-microphone / fa-video / fa-location-dot
visibles en état semi et expanded.

v0.5.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:43:44 +02:00
gilles 6c9ebcaab7 feat(notes): support vidéo + transcodage audio AAC universel
Audio : ffmpeg transcode toute entrée (webm/ogg/m4a) vers AAC/m4a
au moment de l'upload → lecture Safari iOS garantie.

Vidéo : nouveau save_video(), webm transcodé en H.264/mp4, mp4/quicktime
stocké directement. Lecteur <video> inline dans NoteCard.

Frontend :
- Bouton vidéo (fa-video) dans les actions de chaque note
- Icônes fa-image / fa-microphone / fa-video / fa-location-dot dans la méta
- Filtres rapides : Photo / Audio / Vidéo / GPS (avec icônes fa)
- Boutons actions migrés vers icônes Font Awesome
- client_max_body_size nginx : 15m → 200m pour les vidéos

v0.5.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:31:05 +02:00
gilles 11b5c6c92e docs: ajouter Phase 12 éditeur Markdown notes (idée future)
Barre d'outils flottante (titre, liste, code, photo, audio, GPS)
compatible clavier virtuel iOS/Android. Backend inchangé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:18:56 +02:00
gilles b3c365d773 fix(notes): GPS lat/lon sérialisé en float au lieu de Decimal
Decimal Python → string JSON causait TypeError: z.toFixed is not a function
dans NoteCard (title attribute de l'icône GPS). Tous les champs gps_lat/gps_lon
passent maintenant en float | None dans les schémas Pydantic.

v0.5.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:13:01 +02:00
gilles d017a0879e fix(media): corriger import ALLOWED_AUDIO_PREFIXES et strip codec MIME
ImportError au démarrage du backend : ALLOWED_AUDIO_TYPES avait été renommé
en ALLOWED_AUDIO_PREFIXES dans services/media.py mais l'import dans api/media.py
n'avait pas été mis à jour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:07:04 +02:00
83 changed files with 15663 additions and 404 deletions
+27 -3
View File
@@ -1,3 +1,27 @@
DATABASE_URL=postgresql+asyncpg://homehub:homehub@db:5432/homehub
UPLOAD_DIR=/uploads
CORS_ORIGINS=http://localhost:3001,http://localhost:3000
# ── HomeHub — variables d'environnement ──
# Copier en .env et compléter. Ne JAMAIS committer le .env réel.
# PostgreSQL
POSTGRES_USER=homehub
POSTGRES_PASSWORD=change-me
POSTGRES_DB=homehub
# Connexion backend (alignée sur les valeurs PostgreSQL ci-dessus)
DATABASE_URL=postgresql+asyncpg://homehub:change-me@db:5432/homehub
# Stockage médias / données
UPLOAD_DIR=/data/uploads
DATA_DIR=/data
# Redis
REDIS_URL=redis://redis:6379
# CORS — réseau local autorisé (séparés par des virgules)
CORS_ORIGINS=http://localhost:3001,http://10.0.0.50:3001,http://10.0.1.137:3001,http://10.0.1.65:3001,http://10.0.1.45:3001
# Clé Bearer du serveur MCP (générer une valeur aléatoire forte)
MCP_API_KEY=4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI
# Port exposé du frontend (déploiement)
FRONTEND_PORT=3001
+54
View File
@@ -0,0 +1,54 @@
name: Build & Push OCI
# Construit et publie les images HomeHub sur le registre OCI Gitea.
# - push sur main -> tags <role>-latest
# - tag git vX.Y.Z -> tags <role>-latest + <role>-X.Y.Z
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: git.maison43gil.com
IMAGE: git.maison43gil.com/gilles/home_hub
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- role: backend
context: ./backend
- role: frontend
context: ./frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login au registre OCI Gitea
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Déduire les tags
id: tags
run: |
TAGS="${IMAGE}:${{ matrix.role }}-latest"
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
TAGS="${TAGS},${IMAGE}:${{ matrix.role }}-${VERSION}"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- name: Build & push ${{ matrix.role }}
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
push: true
tags: ${{ steps.tags.outputs.tags }}
@@ -0,0 +1,624 @@
# **Architecture logicielle et spécifications techniques pour une suite de productivité personnelle autohébergée**
La mise en œuvre d'une suite de productivité personnelle autohébergée — unifiant la gestion de tâches, de calendriers multi-sources, de listes de courses adaptées au format mobile et de tableaux Kanban — nécessite un examen rigoureux des architectures de communication et des protocoles de synchronisation. Ce rapport détaille le processus de brainstorming technique, la structure de l'API passerelle, les spécifications de l'interface utilisateur multi-plateforme, le moteur de gestion des calendriers et les interfaces d'intelligence artificielle requises pour ce projet.
## **Brainstorming technique et sélection des composants logiciels**
Pour répondre aux exigences de flexibilité et de performance, le choix entre une architecture centralisée autour d'un système de gestion de base de données (BaaS) et l'assemblage de briques logicielles préexistantes a été évalué. Le tableau ci-dessous présente une analyse comparative des technologies étudiées pour structurer les différents modules de l'application.
| Composant logiciel | Rôle fonctionnel | Avantages majeurs | Inconvénients et contraintes techniques |
| :---- | :---- | :---- | :---- |
| **Vikunja API** 1 | Gestion des tâches (todos) et tableaux Kanban 2 | API REST Swagger native \[/api/v1/docs\]2, gestion granulaire des filtres de recherche et des erreurs 2, support natif du Kanban.2 | Structure Go/TypeScript monolithique 4 exigeant une couche d'adaptation pour s'intégrer à un frontend SvelteKit sur mesure. |
| **PocketBase** 5 | Backend général, stockage de la liste de courses et des notes d'idées | Binaire Go unique, base de données SQLite embarquée, support natif des abonnements temps réel via Server-Sent Events (SSE) et gestion native des utilisateurs.5 | Absence de logique métier par défaut pour la réconciliation complexe d'événements de calendrier ou la modélisation Kanban évoluée. |
| **Keeper.sh** 8 | Moteur de synchronisation bidirectionnelle de calendriers | Licence open-source AGPL-3.0, agrégation de flux multiples, réconciliation automatique des conflits, serveur MCP intégré.8 | Nécessite des instances PostgreSQL et Redis pour l'orchestration des files d'attente, ce qui augmente l'empreinte mémoire.9 |
| **SvelteKit avec Vite-PWA** 11 | Framework applicatif du client unique (PWA) | Précaching par Workbox, gestion robuste du mode hors-ligne 11, compilation d'actifs ultra-légers pour ordinateurs tactiles et mobiles. | Exige une gestion stricte des restrictions d'arrière-plan imposées par le moteur WebKit sur iOS.13 |
| **Radicale / Baïkal** 15 | Serveur CalDAV / CardDAV de stockage local | Protocoles standards, légèreté extrême, intégration native avec les clients iOS et Android.15 | Ne propose aucun mécanisme de synchronisation active ou de réconciliation automatique vers des fournisseurs tiers comme Google.16 |
| **Vdirsyncer** 17 | Outil CLI de synchronisation de répertoires DAV | Synchronisation bidirectionnelle robuste 18, support natif de Google Calendar.17 | Processus synchrone complexe en ligne de commande, difficile à intégrer dynamiquement dans un backend d'application web.19 |
L'analyse de ces technologies oriente la conception vers une architecture découplée. Le stockage principal des tâches et des tableaux Kanban est délégué à l'API de Vikunja 2, tandis que la liste de courses personnalisée et le module de notes sont hébergés sur PocketBase pour bénéficier d'une réactivité en temps réel optimale lors de l'usage en grande surface.5 La couche de synchronisation des calendriers s'appuie sur le moteur d'agrégation de Keeper.sh 8, et le frontend SvelteKit unifie l'expérience utilisateur.11
## **Architecture globale du système et passerelle d'API**
Pour garantir l'interrogabilité de l'infrastructure par des applications tierces, le système est orchestré autour d'une passerelle d'API (API Gateway) unifiée. Cette passerelle assure le routage des requêtes, la validation de la sécurité et la transformation des charges utiles des protocoles externes, notamment les webhooks Gitea 20 et les requêtes des agents d'intelligence artificielle.21
\+---------------------------------------+
| Application Cliente |
| (PWA SvelteKit / Laptop Web) |
\+-------------------+-------------------+
| HTTPS (REST & SSE)
v
\+---------------------------------------+
| Proxy Inverse (Caddy) |
\+-------------------+-------------------+
|
\+----------------------------+----------------------------+
| /api/v1/tasks | /api/v1/courses / notes | /api/v1/calendars
v v v
\+------------------+ \+------------------+ \+------------------+
| Vikunja API | | PocketBase | | Keeper.sh |
| (Tâches, Kanban) | | (Courses, Notes) | | (Calendriers) |
\+--------+---------+ \+--------+---------+ \+--------+---------+
| | |
| SQLite / Postgres | SQLite (Multi-Bases) | Postgres / Redis
v v v
\+----------------------------------------------------------------------------+
| Espace de Stockage Persistant |
\+----------------------------------------------------------------------------+
### **Mécanisme d'intégration Gitea**
La connexion avec le serveur Gitea s'établit par le biais de webhooks sortants déclenchés lors de l'activité des dépôts (création d'un ticket, fermeture d'une pull request ou mise à jour d'un jalon).20 Lorsqu'un événement survient, Gitea émet une requête HTTP POST vers l'endpoint /api/v1/gitea/webhook de la passerelle.20
Pour garantir la sécurité de cette intégration, chaque webhook est authentifié à l'aide d'un secret partagé.20 La passerelle valide l'en-tête X-Gitea-Signature en calculant le code HMAC-SHA256 du corps brut de la requête 20 :
![][image1]
La fonction de validation compare les hachages en temps constant pour faire échec aux attaques par canal auxiliaire. Dès validation, la passerelle convertit la charge utile de Gitea (par exemple, la création d'un ticket doté d'une date d'échéance) 20 en un appel d'API vers Vikunja pour ajouter la tâche dans la liste correspondante 3, puis met à jour l'agenda par l'intermédiaire de l'API de Keeper.sh.9
### **Compatibilité des calendriers mobiles (iOS et Android)**
Pour s'assurer que les modifications apportées aux calendriers soient immédiatement visibles sur les terminaux iOS et Android, l'application propose deux canaux de distribution de données synchronisées :
* **Canal iOS natif :** Les comptes Apple iCloud utilisent le protocole CalDAV standardisé.16 Le système configure un point de terminaison CalDAV local (via l'intégration de Keeper.sh ou un serveur de synchronisation sous-jacent) permettant à iOS d'ajouter l'application en tant que compte de calendrier tiers direct.8
* **Canal Android / Google Calendar :** Google Calendar ne supportant pas nativement l'ajout de serveurs CalDAV sans client tiers (tel que DAVx5) 15, le serveur Keeper.sh pousse les mises à jour en temps réel vers l'API Google Calendar via l'authentification OAuth2.8 L'appareil Android synchronise ensuite ces données de façon transparente à travers le compte Google lié au système d'exploitation.16
* **Agrégation ICS unifiée :** L'API expose un flux iCal (.ics) sécurisé et mis en cache, lisible par n'importe quelle application de calendrier sur smartphone (Outlook, Apple Calendar ou Google Calendar), garantissant une compatibilité universelle en lecture seule.8
## **Architecture multi-bases de données SQL et isolation des utilisateurs**
Afin de garantir une résilience maximale et un cloisonnement propre des données tout en maintenant des performances élevées, l'application s'appuie sur une structure multi-bases de données SQL (utilisant principalement SQLite en mode Write-Ahead Logging \- WAL et PostgreSQL pour les services hautement concurrents).
\+---------------------------------------------------------------------------------+
| APPLICATION GATEWAY |
\+---------------------------------------------------------------------------------+
| | |
v v v
\+-------------------+ \+-------------------+ \+-------------------+
| Vikunja API | | PocketBase | | Keeper.sh |
\+-------------------+ \+-------------------+ \+-------------------+
| | |
v v v
\+-------------------+ \+-------------------+ \+-------------------+
| SQLite / Postgres| | Multi-DB SQLite | | PostgreSQL |
| (todos.db) | | (courses, notes) | | (calendars.db) |
\+-------------------+ \+---------+---------+ \+-------------------+
|
\+-----------------+-----------------+
| |
v v
\+---------------------+ \+---------------------+
| courses.db | | notes.db |
\+---------------------+ \+---------------------+
### **1\. Partitionnement physique des bases de données SQL**
L'architecture de stockage isole les différents domaines fonctionnels dans des fichiers de bases de données physiques distincts afin de simplifier les sauvegardes, de limiter le rayon d'impact en cas de corruption et de optimiser les performances d'accès concurrentiel :
* **users.db / auth.db :** Géré par PocketBase pour l'authentification unifiée, les profils des utilisateurs, les clés API de l'application et la configuration globale de l'interface.
* **todos.db :** Base SQLite gérée nativement par Vikunja contenant l'arborescence des tâches, l'état d'avancement et la structure des buckets Kanban.
* **courses.db :** Base SQLite gérée par PocketBase stockant l'historique d'achat, les listes de courses hebdomadaires courantes et les listes types.
* **notes.db :** Base de données dédiée aux notes diverses ("pense-bête"), optimisée pour la recherche textuelle à grande échelle.38
* **calendars.db :** Base PostgreSQL gérée par Keeper.sh contenant l'état des abonnements de calendrier, la file d'attente Redis pour les tâches asynchrones de synchronisation et l'historique de réconciliation des événements.9
### **2\. Gestion multi-utilisateurs et règles de partage de l'information**
Le système intègre nativement la gestion de plusieurs utilisateurs avec un contrôle d'accès strict (RBAC \- Role-Based Access Control) mis en œuvre au niveau de la passerelle et de PocketBase :
* **Isolation stricte (Par défaut) :** Chaque enregistrement de tâche, note ou article de course est lié à l'identifiant unique de l'utilisateur (user\_id). Les API rules de PocketBase interdisent toute lecture ou modification par un tiers si la règle Row-Level Security (RLS) user\_id \= @request.auth.id n'est pas validée.
* **Partage d'information granulaire :** Pour permettre la collaboration (par exemple, une liste de courses partagée au sein d'un foyer), une collection d'association shares définit les droits accordés. Le schéma de partage comprend :
JSON
{
"id": "share\_id",
"resource\_type": "courses\_list | notes",
"resource\_id": "target\_uuid",
"shared\_by": "user\_id\_owner",
"shared\_with": "user\_id\_recipient",
"access\_level": "read | write"
}
Les API de consultation de PocketBase appliquent alors un filtre logique pour renvoyer les objets appartenant à l'utilisateur *ou* partagés avec lui :
![][image2]
## **Module de notes diverses ("Pense-bête") et recherche à grande échelle**
Le module "Pense-bête" est conçu pour accueillir des milliers de notes textuelles non structurées (telles que des références de pièces mécaniques, des adresses, des idées à la volée ou des mémos d'achats passés) et garantir qu'un utilisateur puisse instantanément retrouver une information spécifique sur son smartphone en utilisant des filtres par étiquettes (tags), par dates de saisie ou par recherche textuelle approfondie.
### **1\. Indexation et recherche plein texte (FTS5) avec SQLite**
Pour assurer une recherche instantanée parmi des milliers de fiches mémo sans alourdir l'empreinte mémoire du serveur mobile, PocketBase s'appuie sur le moteur d'indexation plein texte **FTS5** (Full-Text Search) natif de SQLite.
Une table virtuelle FTS5 est configurée dans la base notes.db pour indexer le contenu et les métadonnées de chaque note :
SQL
\-- Création de la table virtuelle FTS5 optimisée
CREATE VIRTUAL TABLE notes\_fts USING fts5(
id UNINDEXED,
content,
tags,
tokenize="porter unicode61"
);
*Note sur l'optimisation :* L'utilisation du tokenizer porter permet la racinisation (stemming) automatique des mots (par exemple, rechercher "courroies" ou "motoculteurs" trouvera également "courroie" ou "motoculteur").
Lorsqu'un utilisateur recherche la référence d'une courroie de motoculteur avec la boutique d'achat, l'application exécute la requête SQL suivante qui tire parti de la puissance de l'index FTS5 :
SQL
SELECT notes.id, notes.content, notes.tags, notes.created\_at, ts\_headline('notes\_fts', content, '\<b\>', '\</b\>') as highlight
FROM notes
JOIN notes\_fts ON notes.id \= notes\_fts.id
WHERE notes\_fts MATCH 'courroie motoculteur'
ORDER BY rank;
### **2\. Typologie et structure des métadonnées**
Chaque note peut être enrichie d'un système d'étiquettes dynamiques et de marqueurs temporels afin de faciliter le tri croisé :
* **Schéma de données d'une note :**
JSON
{
"id": "uuid\_v4",
"user\_id": "relation\_users",
"content": "Référence courroie motoculteur Honda F560 : 4L-830 ou F12-329. Acheté chez Motoculture 77 à Meaux.",
"tags": \["bricolage", "mécanique", "référence", "achat"\],
"created\_at": "2026-05-23T14:00:00Z",
"updated\_at": "2026-05-23T14:00:00Z"
}
* **Ergonomie de filtrage sur l'interface SvelteKit :** Une barre de recherche à auto-complétion permet de combiner la saisie textuelle avec des jetons de recherche prédéfinis (ex. : \#mécanique pour filtrer par tag, ou after:2026-01-01 pour restreindre temporellement), offrant une navigation rapide et précise même sur de très grands volumes de données.
## **Conception du client SvelteKit (PWA et Thème)**
L'interface utilisateur de l'application est développée sous la forme d'un client unique adaptatif conçu avec SvelteKit.11 Elle remplit un double rôle : d'une part, offrir un tableau de bord complet sur ordinateur portable pour l'administration et l'affichage des tableaux Kanban complexes 2 ; d'autre part, proposer une Progressive Web App (PWA) optimisée pour les smartphones sous iOS et Android.11
### **Intégration du thème et des variables de design**
L'application consomme un thème de design fourni au moyen de variables CSS globales (Custom Properties) injectées au niveau de la racine du document (:root). Cette architecture permet une modification dynamique des teintes pour s'adapter aux modes clair et sombre tout en respectant la charte graphique globale.
CSS
:root {
\--primary-color: \#6366f1;
\--primary-hover: \#4f46e5;
\--bg-app: \#f8fafc;
\--bg-card: \#ffffff;
\--text-main: \#0f172a;
\--text-muted: \#64748b;
\--border-radius-m: 12px;
\--touch-target-size: 48px;
}
### **Ergonomie mobile : Todo et liste de courses**
Pour répondre à l'impératif d'efficacité sur smartphone, l'interface utilisateur s'articule autour de patrons de conception spécifiques :
* **Saisie rapide des tâches :** Le formulaire de création de tâches est accessible en un clic via un bouton d'action flottant. Il comporte des puces de sélection rapide de date d'échéance basées sur une arithmétique temporelle simple (![][image3] jour, ![][image4] jours, semaine suivante) pour s'affranchir du sélecteur de date natif souvent laborieux sur mobile.
* **Modification instantanée :** Chaque tâche affichée dans la liste mobile dispose d'actions rapides par glissement (swipe). Faire glisser une tâche vers la droite permet de l'archiver ou de la marquer comme terminée ; la faire glisser vers la gauche ouvre un volet de report rapide (« Reporter d'un jour », « Reporter d'une semaine ») qui met à jour l'échéance via une requête API asynchrone instantanée.
* **Ergonomie en grande surface :** L'affichage de la liste de courses adopte des éléments tactiles aux dimensions surélevées conformes aux exigences d'accessibilité mobile (hauteur minimale de ![][image5]). Les cases à cocher sont remplacées par des boutons d'activation couvrant l'intégralité de la largeur de la ligne pour permettre un marquage facile d'une seule main au supermarché.
### **Optimisation iOS et gestion du mode hors-ligne**
Sous iOS (moteur WebKit), le cycle de vie d'une PWA obéit à des règles de persistance strictes. Les données stockées dans le cache applicatif ou dans IndexedDB sont susceptibles d'être purgées si l'utilisateur n'ouvre pas l'application pendant 7 jours consécutifs.14 L'application SvelteKit intègre donc un mécanisme de synchronisation différentielle systématique au démarrage.
Pour assurer le fonctionnement hors-ligne (notamment dans les zones à faible couverture réseau en grande surface), le plugin vite-plugin-pwa est configuré avec Workbox pour pré-cacher l'intégralité des routes statiques et des actifs indispensables.11
Le fichier d'initialisation du Service Worker ci-dessous détaille la gestion du cache et l'interception robuste des notifications push requises pour éviter les révocations d'autorisation par iOS 13 :
JavaScript
// src/service-worker.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
// Pré-mise en cache automatique des fichiers générés à la compilation \[12\]
precacheAndRoute(self.\_\_WB\_MANIFEST);
// Stratégie pour les polices de caractères et styles statiques
registerRoute(
({ request }) \=\> request.destination \=== 'font' || request.destination \=== 'style',
new CacheFirst({
cacheName: 'static-assets-v1',
})
);
// Stratégie de mise en cache pour la page d'accueil et les fichiers indispensables au mode hors-ligne
registerRoute(
({ url }) \=\> url.pathname \=== '/' || url.pathname.includes('/dashboard'),
new NetworkFirst({
cacheName: 'navigation-cache-v1',
})
);
// Écouteur d'événements Push assurant la conformité avec iOS 16.4+
self.addEventListener('push', event \=\> {
let data \= { title: 'Notification', body: 'Nouvelle mise à jour système.' };
try {
if (event.data) {
data \= event.data.json(); // Analyse de la charge utile JSON
}
} catch (err) {
// Fallback obligatoire si iOS envoie un format texte ou invalide
data \= { title: 'Mise à jour', body: event.data? event.data.text() : 'Événement déclenché.' };
}
// WebKit iOS exige l'appel de showNotification enveloppé dans waitUntil sous peine de révoquer les permissions
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
tag: 'productivity-push-tag',
renotify: true
})
);
});
## **Synchronisation bidirectionnelle multi-calendriers**
La synchronisation bidirectionnelle entre le calendrier autohébergé, Apple iCloud (CalDAV) et Google Calendar (API REST propriétaire) repose sur un orchestrateur capable de résoudre les divergences temporelles et de prévenir la duplication d'événements.
\+--------------------+ \+---------------------+
| Apple iCloud (Cal) | | Google Calendar API |
\+---------+----------+ \+----------+----------+
| CalDAV | OAuth2 / REST
| |
\+-------------------+----------------------+
|
v
\+----------------------------+
| Orchestrateur Keeper |
| (Moteur de Réconciliation)|
\+-------------+--------------+
|
v
\+----------------------------+
| Base de Données Calendrier |
| (Stockage PostgreSQL) |
\+----------------------------+
### **Mécanisme de réconciliation et résolution des conflits**
Pour maintenir la cohérence des bases de données de calendrier sans introduire de boucles de mise à jour infinies, l'application associe à chaque événement un identifiant universel unique (UUIDv4) persistant à travers toutes les plateformes cibles. Le moteur Keeper.sh surveille les modifications apportées sur chaque serveur distant à intervalles réguliers.8
L'intervalle de synchronisation dynamique (![][image6]) s'adapte en fonction de l'activité de l'utilisateur pour préserver les quotas d'API des services externes :
![][image7]
Où ![][image8] est fixé à 60 secondes pour les comptes premium/autohébergés 8, ![][image9] est un coefficient de temporisation de valeur ![][image10], et ![][image11] représente la durée écoulée depuis la dernière modification manuelle détectée sur l'application.
La résolution des conflits d'événements s'effectue en comparant l'horodatage de dernière modification (LAST-MODIFIED au format iCalendar).8 Le tableau suivant détaille les règles de réconciliation appliquées par l'orchestrateur.
| Type de divergence détectée | Action corrective automatique | Résolution en cas de conflit d'horodatage |
| :---- | :---- | :---- |
| Événement modifié localement et inchangé sur la plateforme distante | Écriture de la mise à jour sur le serveur distant (iCloud ou Google) via l'API appropriée.8 | La version locale prévaut car l'horodatage distant est antérieur. |
| Événement modifié sur le serveur distant et inchangé localement | Importation des données distantes et mise à jour de la base SQLite/Postgres locale.8 | La version distante prévaut car l'horodatage local est antérieur. |
| Événement modifié simultanément des deux côtés (conflit strict) | Analyse des horodatages précis. La modification la plus récente dans le temps est conservée.27 | Le serveur distant ou local gagne selon le paramètre par défaut (ex. : "Google wins").27 |
| Événement supprimé localement | Propagation de la requête de suppression vers l'ensemble des plateformes liées.8 | Suppression définitive pour éviter la réapparition de l'événement (effet "orphelin").8 |
### **Protocole d'authentification OAuth2 Google**
La synchronisation avec Google Calendar nécessite d'obtenir un accord d'accès de l'utilisateur par le flux d'autorisation OAuth2.19 L'application gère cette intégration en interne à l'aide d'un proxy OAuth2 pour simplifier la configuration sur l'hôte local 19 :
1. L'utilisateur lance l'association depuis l'interface SvelteKit (Desktop), ce qui le redirige vers l'URL d'autorisation Google munie des portées (scopes) https://www.googleapis.com/auth/calendar.19
2. Après validation des droits, Google renvoie un code temporaire vers l'URI de redirection configurée sur la passerelle de l'application.19
3. L'API Gateway échange ce code contre un jeton d'accès (access\_token) à durée de vie limitée (3600 secondes) et un jeton de rafraîchissement (refresh\_token) persistant.19
4. Un processus planifié (cron) surveille l'expiration du jeton d'accès et exécute automatiquement une requête POST de rafraîchissement auprès du serveur d'authentification Google avant chaque cycle de synchronisation.19
## **Module de courses intelligent : prédiction et vision IA**
L'un des axes d'évolution majeurs de l'application est l'automatisation de la gestion des approvisionnements domestiques, s'appuyant à la fois sur un système d'analyse prédictive temporelle et sur les capacités de traitement visuel de l'agent IA Hermes.
### **1\. Algorithme d'analyse d'achat récurrent (Fonction d'auto-remplissage)**
L'application intègre un moteur d'analyse statistique en tâche de fond qui étudie l'historique d'achat hebdomadaire afin de déterminer la fréquence de consommation de chaque produit.
* **Calcul du score d'achat récurrent (![][image12]) :**
Pour chaque produit ![][image13] acheté par un utilisateur, l'algorithme calcule un score d'élection hebdomadaire en appliquant un amortissement temporel exponentiel, ce qui permet de donner plus de poids aux habitudes récentes par rapport aux historiques obsolètes :
![][image14]
Où ![][image15] est une variable binaire égale à 1 si le produit ![][image13] a été coché/acheté durant la semaine ![][image16], et 0 sinon.
Le facteur de pondération ![][image17] est défini par :
![][image18]
Où ![][image19] est la constante de décroissance temporelle (fixée par défaut à ![][image10]) et ![][image20] est le nombre total de semaines observées.
* **Seuil de déclenchement :** Chaque fin de semaine, une tâche planifiée (cron) analyse la base courses.db. Si le score ![][image12] dépasse un seuil d'élection ![][image21], l'article est automatiquement ajouté à la liste "Pré-remplie" de la semaine suivante sous le statut *Suggéré*, l'utilisateur n'ayant plus qu'à valider ou retirer l'article d'un glissement de doigt.
### **2\. Analyse d'image IA (Frigo et placards) via Hermes Vision**
L'agent Hermes dispose de capacités natives d'analyse visuelle multi-modale (Vision & Image Paste). L'utilisateur peut prendre une photo de l'intérieur de son réfrigérateur ou de ses placards depuis son smartphone et l'envoyer directement à l'agent via le canal de communication mobile configuré (ex. : Telegram ou Discord).
\+------------------+ \+--------------------+ \+--------------------+
| Utilisateur ou | | Agent Hermes | | Passerelle |
| Smartphone (App) | | (Module Vision) | | API (PocketBase) |
\+--------+---------+ \+---------+----------+ \+---------+----------+
| | |
| Envoi de la photo de l'inventaire | |
|--------------------------------------\>| |
| | Analyse de l'image (Vision) |
| | & détection des articles épuisés |
| |---------------------------- |
| | | |
| |\<--------------------------- |
| | |
| | Requête POST /api/v1/shopping/suggest |
| |---------------------------------------\>|
| | | Mises à jour SQLite
| | | des articles manquants
| | | (courses.db)
| | |----------
| | | |
| | |\<---------
| | |
| | Confirmation JSON de l'auto-remplissage|
| |\<---------------------------------------|
| Notification de la liste mise à jour | |
|\<--------------------------------------| |
Le traitement s'organise de la manière suivante :
1. **Réception et routage de l'image :** L'image est acheminée vers l'environnement d'exécution de l'agent Hermes.
2. **Exécution du Skill Visuel :** Un outil spécifique mcp\_productivity\_analyze\_fridge s'exécute localement ou via MCP.21 Il transmet la photo au modèle d'analyse d'image (ex. : Claude 3.5 Sonnet ou GPT-4o) accompagné d'un prompt d'inventaire structuré :
* *Instructions au modèle :* "Compare cette image de réfrigérateur/placard avec la liste de référence des denrées courantes de cet utilisateur. Identifie les produits habituellement présents mais visiblement épuisés ou en quantité critique (ex : bouteille de lait manquante, bac à légumes vide). Retourne uniquement les noms de ces produits sous forme de tableau JSON."
3. **Mise à jour automatique :** L'agent traite le retour JSON du modèle et appelle directement l'API de PocketBase via le script d'outils du Skill pour ajouter les éléments manquants à la liste de courses de l'utilisateur sous l'étiquette "Saisie par IA".
## **Intégration IA : Serveur MCP et Skill Hermes Spécifique**
Pour permettre à l'agent d'intelligence artificielle Hermes d'agir de façon autonome sur la suite de productivité, deux canaux d'accès distincts sont déployés : un serveur MCP (Model Context Protocol) s'appuyant sur l'infrastructure d'API existante 21, et un Skill Hermes local stocké de manière native dans l'environnement de l'agent.28
\+-----------------------+
| Agent Hermes |
\+-----------+-----------+
|
\+------------------------+------------------------+
| Charge à la demande | Utilise le transport
v v
\+------------------+------------------+ \+------------------+------------------+
| Skill Hermes Spécifique | | Serveur MCP (HTTP SSE) |
| (Fichiers locaux SKILL.md) | | (Enregistrement des outils API) |
\+------------------+------------------+ \+------------------+------------------+
| |
\+------------------------+------------------------+
| Requêtes HTTPS / REST
v
\+-----------------------+
| API Gateway / PWA |
\+-----------------------+
### **Canal 1 : Spécifications et configuration du serveur MCP (HTTP-SSE)**
Le serveur MCP permet à l'agent Hermes (ou à tout client compatible comme Claude Desktop ou Cursor) d'interroger dynamiquement les outils de l'application via une connexion bidirectionnelle Server-Sent Events (SSE).29
Chaque outil exposé par le serveur MCP est enregistré au niveau de la passerelle et obéit à la convention de nommage standardisée de l'agent Hermes 21 :
![][image22]
Les caractères spéciaux tels que les tirets ou les points sont remplacés par des tirets bas pour assurer la compatibilité avec les API des grands modèles de langage.21 Ainsi, l'outil create-todo du serveur productivity est exposé sous le nom mcp\_productivity\_create\_todo.21
Pour activer cette intégration au démarrage de l'agent Hermes, la configuration est ajoutée directement dans le fichier \~/.hermes/config.yaml sous la clé mcp\_servers 21 :
YAML
\# \~/.hermes/config.yaml
mcp\_servers:
productivity:
url: "https://app.example.com/mcp" \[30, 31\]
connect\_timeout: 60 \[21\]
timeout: 120 \[21\]
headers:
Authorization: "Bearer ${PRODUCTIVITY\_API\_TOKEN}" \[21, 32\]
### **Canal 2 : Conception d'un Skill Hermes spécifique**
Contrairement au protocole MCP qui nécessite une exposition réseau constante et une communication RPC formelle 33, le système de « Skills » de l'agent Hermes s'appuie sur des documents de connaissances locaux stockés directement dans le répertoire utilisateur \~/.hermes/skills/.28 Ces fichiers respectent la norme ouverte agentskills.io et privilégient une approche de divulgation progressive afin de réduire la consommation de jetons (tokens) lors des échanges avec le modèle de langage.28
Le Skill Hermes développé spécifiquement pour l'application combine des instructions en langage naturel rédigées dans l'en-tête (frontmatter YAML) et un script Python d'exécution directe 28 :
# **\~/.hermes/skills/productivity\_manager.md**
name: productivity\_manager
description: Gestionnaire local de tâches, de listes de courses et de calendriers de l'utilisateur.
config:
api\_url: "[https://app.example.com/api/v1](https://app.example.com/api/v1)"
api\_token: "${PRODUCTIVITY\_API\_TOKEN}"
dependencies:
* httpx
# **Instructions d'utilisation par l'agent**
Lorsqu'un utilisateur demande à planifier sa journée, à modifier ses listes de courses ou à rechercher un mémo :
1. Utilisez les outils intégrés pour interroger ou modifier l'application de productivité.
2. Pour tout report de tâche, privilégiez l'envoi d'une date révisée au format ISO-8601.
3. Pour la recherche de notes mémos ("pense-bête"), utilisez l'outil de recherche plein texte en spécifiant les mots-clés essentiels (ex: "motoculteur").
4. Si l'utilisateur est en déplacement (détecté par le contexte), optimisez les retours d'informations sous forme de listes synthétiques de moins de 15 mots pour faciliter la lecture sur écran de smartphone.
# **Outils exécutables associés**
Le fichier Python ci-dessous, placé dans le répertoire des extensions de l'agent Hermes, implémente la logique d'exécution directe pour ce Skill 35 :
Python
\# \~/.hermes/plugins/productivity\_tool.py
import os
import json
import logging
import httpx
logger \= logging.getLogger(\_\_name\_\_)
def check\_productivity\_requirements() \-\> bool:
"""Valide la présence des variables d'environnement obligatoires.\[35\]"""
return bool(os.getenv("PRODUCTIVITY\_API\_TOKEN"))
def execute\_api\_call(endpoint: str, method: str \= "GET", data: dict \= None) \-\> str:
"""Exécute un appel HTTP sécurisé vers l'API Gateway de la suite de productivité.\[35\]"""
api\_url \= os.getenv("PRODUCTIVITY\_API\_URL", "https://app.example.com/api/v1")
token \= os.getenv("PRODUCTIVITY\_API\_TOKEN")
headers \= {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
try:
with httpx.Client(timeout=15.0) as client:
if method \== "POST":
response \= client.post(f"{api\_url}/{endpoint}", json=data, headers=headers)
elif method \== "PATCH":
response \= client.patch(f"{api\_url}/{endpoint}", json=data, headers=headers)
else:
response \= client.get(f"{api\_url}/{endpoint}", headers=headers)
return json.dumps(response.json()) \# Retour obligatoire sous forme de chaîne JSON \[35\]
except Exception as e:
return json.dumps({"error": f"Échec de l'appel API : {str(e)}"}) \# Enveloppe d'erreur standard \[35\]
\# Schéma d'intégration de l'outil pour le modèle de langage \[35\]
PRODUCTIVITY\_SCHEMA \= {
"name": "manage\_productivity\_item",
"description": "Permet de créer, modifier ou décaler des tâches, d'ajouter des articles de course ou de rechercher des mémos.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": \["create\_task", "delay\_task", "add\_shopping", "search\_memos"\],
"description": "L'action précise à réaliser sur la suite de productivité."
},
"title": {
"type": "string",
"description": "Le titre de la tâche, le nom de l'article de course, ou les mots-clés de recherche de note."
},
"task\_id": {
"type": "integer",
"description": "L'identifiant unique de la tâche à modifier (obligatoire pour delay\_task)."
},
"due\_date": {
"type": "string",
"description": "La nouvelle date d'échéance au format ISO-8601 (AAAA-MM-JJ)."
}
},
"required": \["action"\]
}
}
\# Enregistrement de l'outil au sein du registre d'outils de l'agent Hermes \[35\]
from tools.registry import registry
registry.register(
name="manage\_productivity\_item",
toolset="productivity",
schema=PRODUCTIVITY\_SCHEMA,
handler=lambda args, \*\*kw: execute\_api\_call(
endpoint="tasks" if args.get("action") not in \["add\_shopping", "search\_memos"\] else ("shopping" if args.get("action") \== "add\_shopping" else "memos/search"),
method="POST" if args.get("action")\!= "delay\_task" else "PATCH",
data={
"id": args.get("task\_id"),
"title": args.get("title"),
"due\_date": args.get("due\_date"),
"query": args.get("title") if args.get("action") \== "search\_memos" else None
}
),
check\_fn=check\_productivity\_requirements,
requires\_env=
)
## **Plan d'action pour le développement et la mise en œuvre**
La réalisation pratique de cette suite de productivité s'organise selon une feuille de route progressive structurée en cinq phases de développement distinctes, séparant le cœur applicatif des évolutions d'automatisation intelligente prévues pour un second temps :
### **Phase 1 : Déploiement de l'infrastructure de base (Semaines 1-2)**
* Configurer le fichier d'orchestration Docker Compose pour initialiser PostgreSQL, Redis, PocketBase et le proxy Caddy.
* Initialiser les structures des bases de données de base SQLite et PostgreSQL pour l'application.
* Générer les identifiants d'API sur Google Cloud Console 26 et sécuriser l'accès aux variables d'environnement pour l'authentification OAuth2.19
* Configurer le serveur Gitea pour lier le dépôt de code principal au point de terminaison de réception des webhooks de l'API Gateway.20
### **Phase 2 : Implémentation du moteur de synchronisation et authentification (Semaines 3-4)**
* Déployer l'instance Keeper.sh et connecter les différents comptes de calendrier distants (Apple iCloud et Google Calendar).8
* Valider les scénarios de réconciliation de données temporelles et s'assurer de l'absence de création d'événements doublons ou orphelins lors de modifications simultanées.8
* Configurer les règles de sécurité Row-Level Security (RLS) et la gestion des utilisateurs au sein de PocketBase pour garantir l'étanchéité des comptes.
* Exposer le flux iCal (.ics) crypté à destination des appareils mobiles pour le mode lecture seule.8
### **Phase 3 : Développement de l'interface SvelteKit (Semaines 5-6)**
* Élaborer l'architecture CSS globale en important les variables de design fournies afin de garantir la cohérence thématique.
* Coder les vues SvelteKit adaptatives : vue Kanban pour ordinateur portable, formulaires tactiles simplifiés pour l'ajout rapide de tâches et liste de courses optimisée pour l'usage hors-ligne en grande surface.11
* Implémenter l'interface Web pour la gestion des notes "pense-bête", intégrant la saisie à la volée d'étiquettes (tags) et la recherche textuelle.
* Configurer le Service Worker Workbox pour assurer la gestion rigoureuse du cache d'actifs et le traitement sans échec des événements de notification Push sous iOS.13
### **Phase 4 : Raccordement de l'intelligence artificielle (Semaine 7\)**
* Déployer le module de serveur MCP (HTTP-SSE) sur la passerelle d'API et valider l'interrogabilité des outils à l'aide de l'inspecteur MCP (mcp-inspector).30
* Installer le Skill Hermes spécifique (productivity\_manager.md) dans le répertoire de l'agent local et configurer les jetons d'accès sécurisés au sein du fichier config.yaml de l'agent.21
* Valider les fonctionnalités de tri et de lecture des todos par l'agent IA, ainsi que l'interrogation rapide des notes via le terminal ou le chatbot mobile.
### **Phase 5 : Évolutions futures (Étape 2\)**
* **Partage inter-utilisateurs :** Déploiement de la logique d'autorisation de partage dynamique pour les listes de courses et les pense-bêtes via la table d'association shares.
* **Indexation plein texte à grande échelle :** Raccordement de la table virtuelle SQLite FTS5 pour assurer des recherches instantanées parmi plus de 10 000 mémos techniques ou personnels.38
* **Moteur prédictif hebdomadaire :** Intégration du script d'analyse d'achat récurrent s'appuyant sur l'historique de la base courses.db et l'auto-remplissage basé sur le score ![][image12] calculé.
* **Analyse visuelle d'inventaire :** Raccordement du canal Hermes Vision pour permettre l'envoi de photos de réfrigérateurs/placards via Telegram ou Discord, assurant l'extraction IA automatique des articles manquants à ajouter à la liste de courses.
#### **Sources des citations**
1. Documentation \- Vikunja, consulté le mai 23, 2026, [https://vikunja.io/docs/](https://vikunja.io/docs/)
2. API Documentation \- Vikunja, consulté le mai 23, 2026, [https://vikunja.io/docs/api-documentation/](https://vikunja.io/docs/api-documentation/)
3. Installing \- Vikunja, consulté le mai 23, 2026, [https://vikunja.io/docs/installing/](https://vikunja.io/docs/installing/)
4. GitHub \- go-vikunja/vikunja: The to-do app to organize your life., consulté le mai 23, 2026, [https://github.com/go-vikunja/vikunja](https://github.com/go-vikunja/vikunja)
5. Deploy PocketBase Open Source Firebase Alternative \- Railway, consulté le mai 23, 2026, [https://railway.com/deploy/pocketbase-open-source-firebase-alternat](https://railway.com/deploy/pocketbase-open-source-firebase-alternat)
6. PocketBase \- Open Source realtime backend in 1 file · GitHub, consulté le mai 23, 2026, [https://github.com/pocketbase/pocketbase](https://github.com/pocketbase/pocketbase)
7. PocketBase \- Open Source backend in 1 file, consulté le mai 23, 2026, [https://pocketbase.io/](https://pocketbase.io/)
8. Keeper.sh — Open-Source Calendar Syncing for Google, Outlook & iCloud, consulté le mai 23, 2026, [https://www.keeper.sh/](https://www.keeper.sh/)
9. ridafkih/keeper.sh: Calendar sync tool & universal calendar ... \- GitHub, consulté le mai 23, 2026, [https://github.com/ridafkih/keeper.sh](https://github.com/ridafkih/keeper.sh)
10. Keeper.sh: Calendar Syncing, V2 Release : r/selfhosted \- Reddit, consulté le mai 23, 2026, [https://www.reddit.com/r/selfhosted/comments/1rwnfav/keepersh\_calendar\_syncing\_v2\_release/](https://www.reddit.com/r/selfhosted/comments/1rwnfav/keepersh_calendar_syncing_v2_release/)
11. Create an offline-first and installable PWA with SvelteKit and workbox-precaching, consulté le mai 23, 2026, [https://www.sarcevic.dev/offline-first-installable-pwa-sveltekit-workbox-precaching](https://www.sarcevic.dev/offline-first-installable-pwa-sveltekit-workbox-precaching)
12. SvelteKit | Frameworks \- Vite PWA \- Netlify, consulté le mai 23, 2026, [https://vite-pwa-org.netlify.app/frameworks/sveltekit.html](https://vite-pwa-org.netlify.app/frameworks/sveltekit.html)
13. PWA Push Notifications on iOS in 2026: What Really Works \- WebCraft, consulté le mai 23, 2026, [https://webscraft.org/blog/pwa-pushspovischennya-na-ios-u-2026-scho-realno-pratsyuye?lang=en](https://webscraft.org/blog/pwa-pushspovischennya-na-ios-u-2026-scho-realno-pratsyuye?lang=en)
14. PWA iOS Limitations and Safari Support \[2026\] \- MagicBell, consulté le mai 23, 2026, [https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
15. Looking for calendar and tasks tools : r/selfhosted \- Reddit, consulté le mai 23, 2026, [https://www.reddit.com/r/selfhosted/comments/1s6node/looking\_for\_calendar\_and\_tasks\_tools/](https://www.reddit.com/r/selfhosted/comments/1s6node/looking_for_calendar_and_tasks_tools/)
16. Easy Ways to Add CalDAV Calendar to Google Calendar \- SysTools, consulté le mai 23, 2026, [https://www.systoolsgroup.com/how-to/add-caldav-calendar-to-google-calendar/](https://www.systoolsgroup.com/how-to/add-caldav-calendar-to-google-calendar/)
17. pimutils/vdirsyncer: Synchronize calendars and contacts. \- GitHub, consulté le mai 23, 2026, [https://github.com/pimutils/vdirsyncer](https://github.com/pimutils/vdirsyncer)
18. Introducing vdirsyncer for migrating calendars and addressbooks \- Zimbra : Blog, consulté le mai 23, 2026, [https://blog.zimbra.com/2024/03/introducing-vdirsyncer-for-migrating-calendars-and-addressbooks/](https://blog.zimbra.com/2024/03/introducing-vdirsyncer-for-migrating-calendars-and-addressbooks/)
19. Design for Google CalDAV support in pimsync \- WhyNotHugo (雨果), consulté le mai 23, 2026, [https://whynothugo.nl/journal/2025/03/04/design-for-google-caldav-support-in-pimsync/](https://whynothugo.nl/journal/2025/03/04/design-for-google-caldav-support-in-pimsync/)
20. Webhooks | Gitea Documentation, consulté le mai 23, 2026, [https://docs.gitea.com/usage/repository/webhooks](https://docs.gitea.com/usage/repository/webhooks)
21. Native Mcp — MCP client: connect servers, register tools (stdio ..., consulté le mai 23, 2026, [https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/mcp/mcp-native-mcp](https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/mcp/mcp-native-mcp)
22. Gitea Official Website, consulté le mai 23, 2026, [https://about.gitea.com/](https://about.gitea.com/)
23. Webhooks \- Gitea Documentation, consulté le mai 23, 2026, [https://docs.gitea.com/usage/webhooks](https://docs.gitea.com/usage/webhooks)
24. Gitea API, consulté le mai 23, 2026, [https://docs.gitea.com/api/](https://docs.gitea.com/api/)
25. Sync Google and Outlook Calendars using CalDAV \- Sync2 Cloud, consulté le mai 23, 2026, [https://cloud.sync2.com/sync-google-and-outlook-calendars-using-caldav](https://cloud.sync2.com/sync-google-and-outlook-calendars-using-caldav)
26. Sync Google calendar using Vdirsyncer and Orage \- Ayman Bagabas, consulté le mai 23, 2026, [https://aymanbagabas.com/blog/2018/04/08/sync-google-calendar.html](https://aymanbagabas.com/blog/2018/04/08/sync-google-calendar.html)
27. Organize and sync your calendar with khal and vdirsyncer | Opensource.com, consulté le mai 23, 2026, [https://opensource.com/article/20/1/open-source-calendar](https://opensource.com/article/20/1/open-source-calendar)
28. Skills System | Hermes Agent \- nous research, consulté le mai 23, 2026, [https://hermes-agent.nousresearch.com/docs/user-guide/features/skills](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)
29. Building a Server-Sent Events (SSE) MCP Server with FastAPI \- Ragie, consulté le mai 23, 2026, [https://www.ragie.ai/blog/building-a-server-sent-events-sse-mcp-server-with-fastapi](https://www.ragie.ai/blog/building-a-server-sent-events-sse-mcp-server-with-fastapi)
30. Mounting an MCP Server in a FastAPI ASGI Application | CodeSignal Learn, consulté le mai 23, 2026, [https://codesignal.com/learn/courses/advanced-mcp-server-and-agent-integration-in-python/lessons/mounting-an-mcp-server-in-a-fastapi-asgi-application](https://codesignal.com/learn/courses/advanced-mcp-server-and-agent-integration-in-python/lessons/mounting-an-mcp-server-in-a-fastapi-asgi-application)
31. panz2018/fastapi\_mcp\_sse: A working example to create a FastAPI server with SSE-based MCP support \- GitHub, consulté le mai 23, 2026, [https://github.com/panz2018/fastapi\_mcp\_sse](https://github.com/panz2018/fastapi_mcp_sse)
32. Build an MCP Server in TypeScript: From Scratch 2026 \- Digital Applied, consulté le mai 23, 2026, [https://www.digitalapplied.com/blog/build-mcp-server-typescript-tutorial-from-scratch-2026](https://www.digitalapplied.com/blog/build-mcp-server-typescript-tutorial-from-scratch-2026)
33. Code execution with MCP: building more efficient AI agents \- Anthropic, consulté le mai 23, 2026, [https://www.anthropic.com/engineering/code-execution-with-mcp](https://www.anthropic.com/engineering/code-execution-with-mcp)
34. Adding Tools | Hermes Agent \- nous research, consulté le mai 23, 2026, [https://hermes-agent.nousresearch.com/docs/developer-guide/adding-tools](https://hermes-agent.nousresearch.com/docs/developer-guide/adding-tools)
35. Integrations | Hermes Agent \- nous research, consulté le mai 23, 2026, [https://hermes-agent.nousresearch.com/docs/integrations/](https://hermes-agent.nousresearch.com/docs/integrations/)
36. How to offline PWA? : r/sveltejs \- Reddit, consulté le mai 23, 2026, [https://www.reddit.com/r/sveltejs/comments/1dopoy2/how\_to\_offline\_pwa/](https://www.reddit.com/r/sveltejs/comments/1dopoy2/how_to_offline_pwa/)
37. Self-Hosted Cloud Storage, Calendar and Contacts with OxiCloud \- Pinggy, consulté le mai 23, 2026, [https://pinggy.io/blog/oxicloud\_self\_hosted\_cloud\_storage/](https://pinggy.io/blog/oxicloud_self_hosted_cloud_storage/)
[image1]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAAAvCAYAAABexpbOAAAH30lEQVR4Xu3cB6gtVxWA4WVvqETsFU2sxK5gQXOVWLFFrEQhWBF7wYrw7NhbLDGGRFGU2BuWWK4FK3axoQZbsMXe+/7Ze+Wss++55l55+s67/B8szsyemT1zzn0w6629ZyIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSTq4nNLijy3+1eLXLS7U4rdjnXhQizeO5VeMY6rPRN/GMbMjW5xtbizuE/24P7W4yWi7yGLzkje1+FGLt7Z4eYurRr/2vM7trn325rlh+FD0Y59b2m7d4jItzt3iBS0eVbZdr8UXWnyutOHG0fdPN2xxaosXt7hoaT8QuP78bX4W/Td7y9Ie6yX/lnz+tMXPWzylxbnqTmfii9H7OGzeMLl0i0vMjZIkrRtuatXjWzy9rG9G3+eOpQ2PG+2rnB49uZqdt8VXWly9tJ2zxWdbXLy0pce0OEtZ53yHj+WvjfWK9Xrt6TqxdV88qyy/psW9xvK+6Enil1tcMHeI/r343uCar1C2fb4sv7vFyWV9X1ne37ieh8yNKzwvtv4G8/r/01nnhsknYjmJZ32317uThA2/nBskSVo3801wVcL2z+iVrmojth6bPtzitNh6U352rD7mVrE6YaPCV30r/ruEjbZ5X3w0eiUMR7d4w1h+8vic0cclx/KdYzmZrFW9n7Q4rqz/Lys4H4mDM2HbmBsmJGgXLusk1Lu93p0mbMfODZIkrZv5JrgqYXtwLO/30tg+YSNJO1/0bQwnVrR9f2pL558bmlu0+HOL10Yfuqp2mrBdrcXHWryzxR2mbSRS5xjLT2zxjLH8pOhDhh9vcdvRdoEWX2rxgOjnPn60g2SvJqf0w7UwHMpxFeekGjdXkGijascxnPMbY/l7LU4a+/DdqPoxJHzNFq+Kfp7fR68I/ic1YWPo9gnRf9fEcCP9kwDSd6Ja+OPo++d3oZ/sK5fPPtbph2us/RwVvR/+Dndq8YsWfxmfDxv7zGrCRhX2b7G8781bfDr6uc5T2l/Z4l0tToxFwvb8sfzXsU+9fly+xfXLuiRJa2dOelYlbHh/i0uNZRKajdh6LF44PklA5u2sM/9rN24ai8SEZC+HKHeasHE+5qMxz+1XsUjQZrWv/J7gnCRqDOOSNGRi+YjoSQOo/syYw8ewaE0O+G3+fsYevZ2EMttu0+J9i83xnRZXjp7AMSTN3DpwDVQ9QR+7rbAx3475ixXbsn/6ps93tLjiGXtEfKosf7UsvygWCVv9Henn7tETysR3xHZzCtNcYWPYcrMsc86U53xkixtM7Vlhe2/0vz/majEePTdIkrRO5qRnu4TtmOhzynBobJ+wfXt8UrGat7NOtWYVKjPcXDNmVEE4Ph8A2GnCRhUmsT0ThooHGR47Nw5U2TiOytg3SztVNZIgvL20z24U/eED5tGRgFHl4ZoIqmKrfqdEwpReHT1xzGN/MNo5drcJG0h4snoItmX/9E1SXpNLvK4sM6k/zQlbvcarRO9ns8XTxj7YbcJGdS2vn08qnekf43Mz+jzJxH75b+meYx1zpRXPnBskSVonc7KwXcIG9s0b28ZYr5jEf/+yzrDetcp6Dk3Nsmp1lxJgGK0iGTxhLO80YbtSWabiwzBc9bboTyCCOXao/TJMmetfL+08oJBDqHlN6dRYnt/GvLd7Rx++Y4i3IomZv0eqCRLDo1TGZhxLMrPqydhqTtjuG31oNbFt7j+HEFO9Hp4STgyR14StokJIFZEqJ9eYD2Pk53ZPq84JG39H+mY4l8+apGa1cTOWnyRlv5r8k/TerqxXDPlKkrS25hvsmSVsDAtiY6xXJDR1Xhb91L5IzKhScROv6vBWNSdsJDuZEO4kYatDmyAJmI+p587hXOaNJV4tkUOBf4hFQkAF6rpj+TnjMzEEmE+c4sjo33lfLJ+fPq49tdUnaGuCtNHiqWX9iPHJsQzP1oSjVs7SnLBRcfrhWGbYk23ZP33z9CnJ1OVGG2qCV4dHmR/IPDPUc9DPMbH8PXLI9/Xjk9eqrDInbA+NRd+8luRlZVu28zvU/yDQXod0bxb9gZBVSGAlSVo73Dh/F/2mRtWJOV6nR69WEA+MxXvYfjOOOT76Dfjw6NUXtjGfiJs684NYZzI5eHdb9s8NtmI4i/1Pa3G3aVvFpPj7RZ8Px7u4DhntXDv9ztfOel4773jL60t5zbzbCwzbZT/ELUc788+o5nF91xhtYCj4PdHntZFspcvG4ulVfCD6BP3vxtZ9aaeNatvFRhsPLPAE7Gb0By3uEYt35NWJ9g+PXrXkKdycJH/76BW9WmEjyWTfVN/Dxu+UPhn9nEdErwhyDAl1nYD/weiJOMlQTaAZImbIlqoqCSt974veD9eY/VBZJLnidyM5yzmEPChAwrjq/Xv5Hjb+hvzd+fdHRY7fP5GUMoTLuXjIJZ3U4iUj8jtXNaFP/N6ZcEqSpD2MpHJdMLTLvLz9jaTrYHTX6A+rbPcC45rUS5KkPawOox5o9WGF/YlqW52bd7A4Nnp1jjlsq9QncyVJ0h63l5805KECkjWGi/cShv0lSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIkSZIOpH8D+E3uZAw/834AAAAASUVORK5CYII=>
[image2]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAAAxCAYAAABnGvUlAAAJM0lEQVR4Xu3bB4h1RxXA8WOPvfdCbLHFHo0oahTR2BW7oHxExRYLNkQ0iLEgGlTEgi32oFEs2MC2ltg1dkUlUdHYEnvDPn9nTt682Xffvt287Lcf/H9w2Pvmvt07d+7snfNm7ouQJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmaueRYoLWwXdfL9pR0QLlPiRPa9gVLPLjEF2a747ASvy9xzq5sytVKHFdiYyhf1X9LHDsWFueIuu867fWF2utE3X5a4sZdmVZzmW77NjHfrsvwvsPHwuLSJd46Fq7JuUpcocTHxh37CfX5RexOfdbVrgeX+HPUuuOuMd8HaF/Oaa85kPqpJJ0t7lbijUPZeDO8folnDGXLbIwFK+K43x0LG/Yd3L3+dokbdK+/V+KQ7rVWc2i3fanYfO2nHDkWdLYzEB5U4qpj4RZ2I0FaFf1wt+qznXZd5kbd9odjvg+Ac9pr9nc/laT9bpWEbbs2xoIVPKDE82L62GPCxkDz7O71d0pco3u9LucrcZ62zazEudvPLDvQXKD9ZDno3TE/EF40ptu/x7mTLF9k3NFMDYRPHguizubSxtuxWwnSKk6O3avPVLum87af9NHsn7RtltNvuf43ba/pA1zvMWHjnFbxiRKfLHFSiRdGTaTWZX/2U0nak/qEjaVHlki+eebeiiUFbspfjNlAcPkSX27xypgftDa67beX+GrUY7AMMeWdJW4S0zfiMWH7cYkbdq+XJWyXiOUJFku5J0at5zu68n+W+E2Jh0U9/t9K3K/Ef0qcUeJ2Jf5Q4u5Rf/9bJW7+/9+snlviGyU+2l7fpcTpJV5W4pTYnCifFUdFTWK5Rixr4Y5R601SlMvIBAM67cXS2O+i1gn5nqNLfLDE+0pcrO3rvTnq+47oymg7BtZHxPRA+PmxoHjOWNB5VokPlPh4ia915fQ16vb1mJ8tyjbg+NkGU22+qF/eO+rxPl3iXq2s19fn4q3sKzFdn43YXJ9jovaZx5U4rZVhUX2o+yrtml4a9bpwXX/WtrlWf2/b9NeftG3QB9imD/T9nnO6fdRzYnsVdyrxmbFwgbPST7OOO+2n221PSdpT+oTtylFvkNwQEwNl/0wbgx+DUt70wXb/AO9G+/mSqM/FgUSw/53ePUu8rm0fV+Kp3b7E77LsyaD4o9i81MEyzlTCxnLubcfCDn8z8Tzcu9o2iRwDLBg0Pte2qWMiSTt/235ViX+3bc4p63jhqIMLGEip/7WiDiDrwvESbcUAmvrrlwMhqNOh3b4cCBPtzSzKIjkQMoPzq/ldkwMhiXPvZsPrER8Gsm3f0JX/tdvO9ka2AYlJfx5jmy/ql8zwco3Tnbvt1NcnEwQSmqn68KwVxvqwTX+nHIvqQ7t+pJWlqXbtkZDlNSOxpO/xgaJPjMe69H0AnFPOcNGnt+PVY8Fgqp/yf3R299OdtKck7Rl9wgYe7CdpS9zw3ta9Th+KehNkueFNw76N9pOZhFWQBDLo/7IFx7zK3DsWDyy9ZTNsy/BFhbH+HCufq8rtW5X4R9RZwNe3feATe2IWJwcSPvVfs9uXSB5OGAvXgBlKvjDCNckEJO10IGR2cGrWJAfCB7Xt3rKB8L3tJ9eX2ZFlSJbpG/x96pLG82Gpj0Q72+DWrTyNbT7VL5lh4/eI/vnI1NcnExqSm6n68GWdRfVh+5Hd60X1oV3zw0Ja1q4pj/XQEo+N+uHjS3Pv2FyX8f+Kc0r06fyCwuizC4IZ6Sn9NRr7Kcnw2I7r7qc7aU9J2jPGhO3qUZdQEje8XNLrvT/qDZab+0HDvo32kxvp1M2+NyaEHPOJC8rGgaXHlxV2krCx/MSg1mMplEEBHPfpbZt2eH7UWZA0Dm45kLBNkjcieVg2UFAX/uZUTBkHYQbCXAbuB+x+IHxMzBITEpDx2aBVBkKi/x0sOz/6zSFRZyn7WcBFqA/L9C+KugydxoGdPravbYOZLbY5N9pgbPNF/ZLZt+tGnWV+dNTEYtTXh7bDyTFdn0zKxvqw/fC2D4vqc0TUvtZb1q6JpIgPPSz7XS7q7B/17Y19hXq9JWZJKOeUliVsi0z1F+yLzcfOfsoS6Vb9NOu40366k/aUpD2DGyY3917eDFlmYBBjBiBv5jxcDGa0SGSeFnVAYqBIuXTIMg9LHVeKmmzx7Mro2jFbOkp/ivkBmkGSOi1KgNKpUb/NughJEM8MTeH5mBxAWT4laU0viFl7XDbqs0Y9lmPSK2J+IPlj1BkPls+u2MrOKPGeM9+xPnlcBj+W5ZhdOW8rO6X9vF7U9+VzVswWPipmCTfn19efa8zMB/IaJLbv0LbvH7O2f0rUwXPqQW+w/6ixcAGWuWk76vfDrrxPKKgH/Sxnx0BSSBvwbBltMLb5on65r8RfuvfQrzlnZowyqe3rc4tWxvWfqg9ti7E+vOcJbR8W1Qd8cNpOuyYS4fz7XEPOo8fxMxlim3r2H8rGPk39VkG/WvQsWeqv0dhPj4+t+2nWcVk/5XpN9dOdtqck7XenR51N4qZG0pIY3Lg5Pqm9Zkbk51FnePLTdj7gnMEn5FvG7GHnvNnyCZ3nanhgmBttj4GO95Kc5YwaSzn5N/k9kkkSH17/K+oMyOjXUfeT6J067MNWz7CRlJ5Y4vuxebmSwYLydI9uO5efaBsGBerH68e3/Qya/fM1D2z7+/esCwkpbfzaEi+OmqQknt05PmbJJ5GYjflU2/5t20cbcj753sPb/hxQOQ7lfZ85KerzTvvavv4YI5anWBLbCteNum9ETfBob9qav039qA/bea7ZBodFbYOjY7rNx375kKjJCcu0PPuVs5MkT/l8Zl8f5PWfqs8PYnN9Xt7eQ5/Pb2tirA/uG9tr10Tdc0n/mV05/7tcW/7Gaa3smKh9gP9dZPvyf5x9mvNZ1l95ru/YqN8Y3cpUP+VLTFv106zjsn7K9ZrqpzttT0k6YHEjfM1QRmIlSZKkPeLIqEsPudTCjAmfeiVJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ0m74Hwd4fnVFJs95AAAAAElFTkSuQmCC>
[image3]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAZCAYAAAAv3j5gAAAArElEQVR4XmNgGAUjDXABsRi6IDGADV0ABxAC4iAgvgzEJWhyBAEjEG9FF8QClgLxIyDeA8T/GciwiIUBoplYYMFApkWgYKOLRRwMoxYRYdEaID6Dhs8C8Scs4iBcANGGAmAWlaJLEALk+qgcXYIQINeiCnQJQoBciyrRJQgBUi2yY4BY1IguQQgQa1ETEN8D4m8MEIv+AvFDIN6GrAgfINYiqgBzdIFRMAooBgBoKC+ZFf17JgAAAABJRU5ErkJggg==>
[image4]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAZCAYAAAAv3j5gAAABNElEQVR4Xu2UP0tCURjG37DUJLKpwIaWPoM1tDe1GIFDH0GooaGhqaC+Rh/ANVwaWiKUatDZEEEQHIJAEBPqeTnn1rkvR+97sa37g99wnvPqc/94JEr4D6TgOXyFz9aj0ISCtAw8XMJbuGjX1/ALHgcDUSzAOxl66MAJ3LbrIpkivkMVfIX3MvTwCIdwy653yRQ1fiYi4MemKcrANWddIVN04WQzyZKuyIUfXxc+wOXw1nTiFOVhHQ5gC66Ht2cTp8jlBr7DPbnBVOn3DAS+wA9Pzp6aj3nJwRHsw1Wx50VzR3wE9uGmyDtkfhAHIveiKSqT+cKmyHs2PxS5lzhFNSdbgZ9wDDecfCqaIn4fbXgCl2x2Rab8LBiKQlPEFMj81/H5eYNPsOQORKEt+hN2ZJCQMDffwbY9lx5URf8AAAAASUVORK5CYII=>
[image5]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACsAAAAZCAYAAACo79dmAAACjElEQVR4Xu2WW8hNURSFh2uEkksupZT7PSVFSqQovHnx4pIXUkKiRMmLa5G7IvFCijyIEg8S8iIpIaHkWiSXSIQxmnP+Z5119vl/qf846Yz6OnuNNfdqnrnnWnsDDTX0f6gbeUlGZH5bsoHcJ7fJJTK+LOIfaAf5RUZn/jayk7Tz8RTyigxqiqixhpCvqEy2K/kAq26qw2RL5tVM58gJVCarPyFvTOJJqvbezKuJZpKDZA0qk+1CvsEeu+KkzuQRmRpBf6k22W9+XaH25DrpheJkpa3uiwPkCiw2tIk8JZ/IZnKEXCRvySnS1+PGufcTtpbaTrrhY/HCvUKtIKv8ulqy6tdjPidU5YnJfB+yzOceo3Sa9CfPyQPYEwrNgcVu9PEC2H0TmiIK1IPcJB18XC3ZubDFlpJnsJgvZFISM9L9lYknLXF/beYfJx/JQHINVvVmtY/MTsZFyQ4nn1HaYN3JaVjcnQiihrqXJ6sWkH8581Wo1+QN7AxvVqPI+cwrSnYPOZuMQ/thsb19PNjHebJqIfkPM1+K1lmYT+RaTd7B/l2gCupmbYK7Hqcq7vLrVKq4YtWvUrVkNV9UWb1g9Pi1uZRHrPPHUlvkldUmuIXKI2UabOOEIlkVIdVi91XFVOrh9bBN+J6cKZ9uWYdgC49NvJ6wTbWbdHJP/XuPzIoglJLV+dsv8XRyXIUdkZIqOg+2uWI9PQ3du8jHzWo+7Jz8AbtJ/1SnRGgAOQn7yHkCO5enJ/NSJKtviKPkAqxPt6N0bM2AvWAUJ5a7r3XD06t9svutpmo9m0rVjY8h/caRmSqNaTUNQ8vJ1o30fatk1+UT9Sb1aRx731H+sqg7qffie1dHXMdkrqG61W+lXaFLpGlIJwAAAABJRU5ErkJggg==>
[image6]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAAaCAYAAAAqjnX1AAACAklEQVR4Xu2WPUiVYRTHj6WZiBgkZi5K+AHSIIKTBBni5JSCmFtDih9DariIRgWmBFIUKjg0JIgIDS2CRg0GNfgFOgg1FA6pCCVEmZH+/5xzvY9P3Knr20vcH/x4D+fceznv83lFEiRI8H9TD/fhN7gGV+BXy23BVfge7sKf8JJ+LVim4V2Y6uRmRZssdnJ5cA9mO7lAyIJvvNxp+B1+8vJk2U8EQQu85uWqREdxzMuz+RkvFwgX4Ckvd1+0Sa5Vl2RY6OX+GfPwt+hSCCVnRRtc8Athok50qgf9QpgYEW2y2i/E4KQ9T8R4Hgsf4A+Y5hdEN9kQvAUn4VXYAbfhO3jG6rwAGuBNqz2A3fAF7JMotaK/MwBfwnSnFhPuXI7iay8foR82WVwBa5z8U4srRZuPwMaeWVwAv1h8EW7CTNGXm4PnrPYH5+EiXIKfRZvk9cerkPn8w0/q+clRZv6ORI+uEtFrlOcojy/3SHsOWy3OFb1WCUf0lcVxpchsF32JHqfG06BRdOpcpmCzxTmi1yq5JzrFcecxLLX4Cnzo1DrhBrzs5AhHMtIkZ+2XxWVwR7Rxwj8t5Rb/FZzKYdgLn4hupAicyo9ydFdfh+vwrej6HRddTqNW5+bilHNUOTvHTga87eV4jZIkmCLR44pxoDwSHZEbous1lHTBCdjmFxKEjQOBmGDwSWktuQAAAABJRU5ErkJggg==>
[image7]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAAAwCAYAAACsRiaAAAAEoUlEQVR4Xu3daaitUxgH8GWeyVQoH6RkHsoHUmTIPOSLqZAhY0QZCiFFpkRCQhJKMnxBGTJlHsqQSLiKzOIDMsZ6Wmudve57z7l3x5F9jt+v/u211rvP6d3v/XCenvW++6YEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw9zw0XJjnrsq5cLgIADCpjst5Yrg4B22c8+NwcQbL5ywYLgIAk++RbvxnN34r55BuPt98nrN0He+Q81F3rL8ON3fjSRT/Tm/kHDU8UMVnObybr5uzTTcHACbc5t145ZwPuvlTOct28//C6Tk/5ZyTc1/O0znr5dyb83LOVvV9x9ZjsXZXXbs754dUCpY96+sfOSfV42fX1/BMNw59wXZZN54ta6ZyrnGNW/G01Ojw2DZLo5/rz7n39nAhe3i4AABMrrW78d45N3XzvpibDXul0gmaLq/lrDF665Q4vyhEDq3zKN5eSqVIeSXn+br+W86ndf32VLb+wgo539dxdNBWqeNVcw6q4xDbis1qOe928/7YbPkwjbp7X+ask/Po6PDY7unGcZ2W6+bvpFKAx+9/r1sPHw/mAMAc8WYqnZ9J03eOolv0ah3fODi2X86zqXyOfiv3/FSKl7443TbNvC14Xc6Bw8UZPDBcGEPcO3dMN/8959acDbq1sHUqhexMouMYRXCzWyqFWCtWQ3QHV+/mzUzdOABggh2WlvxHPLpX0RVaZnhgTLEtGQXIdImu2XQdttCfVxRjz9XxDd2xeD2xjndOpSPXuk0HpFIUXVrnYcOcXbp5E9utS7oO/1TfvQvf5HwyWAsrpoXvPRvadbiQyrm33xXXMz73dH4ZLgAAk++2tGihck0q93ldkUqHKgqBg3Muzzkz5+qcc3Muqu/for43OkOzadyCbcs6jnOMAnStOn89lfOKbdMm7tfrt0SbI9Oi1+GFnDNyLsnZN5XPHNdgxzTq9v2cc1oqhec+dS1E0RVFYO+xwTy2bPsHP8ax3XChiqde2/lHodqPe9MViADABIt7yOIPeyTGzRepfAXE+ql01eKpyiO646fW119T6Vb1BdFsOSuV84rz2KOOI9FdigcIYnxKKsXS+zm35Gyf823Ok/X41zkn1HGc44OpiC3F5uJUPnt0pOJ9URg2/dZp+w6zVgidXF+/S6VgjevTF2SxTflZN2+iiIz3xcMU8eBE3F+300LvWLx2HWZKXJPY3o6HKe5I5cnQ3nmDOQAwR22Uc3QafVdZdNyuHR2eetoyiqAopv6Ngq3djxWdqvbEamzNxrg9Hdnfs9Xrf7aJn2nrUUi1G/8Xp3XuwgX1tRVs7RrEtuamqXT2Hq9rzaQ9kRnFW5wrADAPxFdntG3AZpP6Gl+jEdt/+6dR8RI38kdBN91N7pPo+LRocTWd+3OuTKUYiy5c+8y757yYygMKUaxGxy46Wl+VH5sSXbZJEcXqguEiADA/XJ/Kd37NNyulsuX7f3FnGq+rCADMQdGJiv+HEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5ou/AEZs11cugIpBAAAAAElFTkSuQmCC>
[image8]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACUAAAAaCAYAAAAwspV7AAAB3klEQVR4Xu2WSSiFURTHj5mlSNgYMmztyFAWWLFQSlnaEEqyUFIWhiRhI5QlpSgLQ4qFDbEwFYUMhQVJspB5+J/Ovd51lXpPn2fx/epX955z3+u8c+/37kfk4uLiX8rhO7yD+3AH3qrYFdyFh/ARPsE8+ZizzMM2GGbEFkmKSjdiCfAZxhgxR4iGy1YsHN7DUyvObNsBJ6iBFVasgKRLI1aci12wYo6QDEOtWBdJUXzWTIJhqhX7M9bhG8nW/guiSArasBNekgnPYKSd8IUykq3rthNeEgs76esT7TNDJEUV2Ql/cgQfYISdAA3wGvbAJjgL22EVbIErME2tHYQnMAkWwgM4AZvhAJyDQWrtj/CTxV1asuIm03BUjVNIzl+WmveT/AlrbkjWMI3wGIao+TnMUONvxMFNuAUvSIri64SvFo4nfq4UpmCtGsfDVyPHZ6jXmF+Sp6h6kk5puHPZxvxXTMJqNebD/GLkeCv7jDn/SF1UHRw3cnswx5j/Cu6ULoq7bBbVQbKFGrtTZlF88ecac5+pJDkLq7AYjpFs9zAsIXm74A6Ukvyl8BsFr8mHayR3KX9HK8kbyQx5ivYZvmaYAJIDq58eHnOO4yyP9YHmMa/juJ7r7wlUurg4ygfgfl6iYwxhvQAAAABJRU5ErkJggg==>
[image9]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAbCAYAAACnZAX6AAAAxElEQVR4XmNgGAUjBngD8X4oPgvEjUDMhCTPiMQGgxogfgrEmlC+CBC/AuJaKL8MiD2hbDAIBeL/DBCbkMFsIH4NxMxAvBeI2WASIIEHQPwQJoAEQLaADIsA4snIEmRpMoNKzEQWhIICBojcVSAWQ5YIh0okIwtCQQ4DRC4PXcIKKhGALgEEdQwQOZBrUAAoHs4D8SwkMXEGiB9WMyAMDAFiBSQ1DLJAvAGIjwDxDiBeDsTWULlJQHwLiBcxYIncUTB4AQBczSfTPPQZ5QAAAABJRU5ErkJggg==>
[image10]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAZCAYAAABD2GxlAAACGElEQVR4Xu2WP0hWURjGX1MaClHDAkX8s4jWYkGiLX6LixE4iYs0aoMIOoUNDYK5RFMkBFIETRH9NUnRqCmhthDNSRF10BANhVCfh/d8t3PfvNdrSXzD94PfcJ9zuOe9373nPZ9IliyZQx2chJ/gF9gHc0Izjk6FDRxd8CoshqfhFfgcNvqTfMrhGuxw12fgN3grmJGcU6ILvoSvzViaj3DP+Aae9Cf53IczJuNTbsECk8dxA66KLvZLogucEl1vUfSNdcIT/gQfvsYV+MzkKdEnazN5UrYlusAJWGnDKMpECxkx+UWXD5o8KXEFjssRCrwsWsiwyS+4/LHJkxJX4HvYA0fhNHwLa0IzPJpEC3lg8lqXc3f9DXEFjsG78vu7G4BLsDSY4ZGS/1/geQlviirRte55WUDUK+ZNmD8xeVJYIHdzEnJF15q1A6REdPCRydOb5I7Jk8IC+Y1Z2KA35M/usAs3TRawDF+ZrFm0wHaTJ4UFvrMhuC16334vY3NnNudlIdio7WAv/AkLvawetnjXcbBAbgZLK3wh+lrTNMghLY298Ae87q7PwgV4M5ihDZ2vhjeKPDMdeXAHfrADopuDp8c1d80fgPN4tOanJx3EJdEj6DP8CrtDowr7FYtkDzsIfl/zcF30QSg/n++wyJt3Dj51OY+7h6J/HI4FFsEzN2MZEu2RGQlbEj/wjIV/YqttmCXLP7APsqt38mXoMa4AAAAASUVORK5CYII=>
[image11]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAaCAYAAABCfffNAAABTUlEQVR4Xu2TvStGYRjGr5LPQkliVBiQzSSDxeIPYDUog5JdySAJi5QsShL52M0MFmUS/wGTlJJ8X7f7ed/3nOt9Tijj+dWvzrmv5+PcnecBcnKECnpF1zT4T8bpJ32h7ZJlMUQHtZhFJb2mR/CNttJxFOv8nm5qkMUEXaet9Im+0o7UiHIG4B80pkGMKngXbeF9GT55uzgiziz9oM0axJikq4l3m/RI32hXol7gmN7A/52Ns+fT1AihGt5Fi9QX4N3sSr1AHX2mKxrEmKKLWiRN9IG+027JjGH4R4xooNTAu7AFY8zBF9rXgCzBD0e9Bso0nddiggb4EbVueiS7oOdSK6MWfrsbNRDsBFk3h4mazbGN7b8Zdux3SnGJGXpLT37wDL6JHdW+75lAZ6iNwi/xHu0NWRG7F3fwgX/xwCYHNuhlqPUn6jk5Ob/kC6haTtNfYAB1AAAAAElFTkSuQmCC>
[image12]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAZCAYAAADTyxWqAAABFUlEQVR4Xu3SMUhCQRjA8S8IkYakwSaHlhoEC1psiEAai4agrWYHNyEnawz3AqM1ot0wQWhoq4YkCGpxcLO5KYzyf9yp50f5sIYg/MNveN+9d+94KjLq/7eKCu5wgzISKGHauy+wPTxj3psl8YoHbxbYMj6xoBfoDAU9HNQxPjChF+gIK3o4KPOAOVkVKenfNIIx7zqwWbyI3dBo4RZr/k3DNIltnOBJ7Kbv0v8dw2hgzpt1i+JKD11LYjfc1AvfZU5S00PXAeoIuesdnGO3e4fqFG/IS++jjyODJhbdbErsJmmxz3zZJWLYxz0enaKbdzKnMy+7xro3/3EzYk9rfgTzd/lVORxiC3G1NnQbuEBWL4z649pmjS7ov8S0/QAAAABJRU5ErkJggg==>
[image13]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAaCAYAAAB7GkaWAAAAfUlEQVR4XmNgGMrAHojfAHEgugQIeAPxSSBWR5cgD7gB8XYgvgnEnsgS0kC8BYiZgfgsEK9DlswGYhsgVgDif0CcjywJA61A/AOIhdAlWID4ORAvQZcAgWAg/g/EtkCsBMQtyJL9QPwEyp4NxNpIcgwmDBBvbADiEGSJkQAA9EAS9Xxtj/4AAAAASUVORK5CYII=>
[image14]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAABRCAYAAABv7vp/AAAEMklEQVR4Xu3dW8hlYxgH8Bc5H8Z5mhjHCCUuKMQVF6KIi7kQV5RcjRukRG6cSnLmTiEhyvmcFEIOKTElVzRyusCEifA8rfX1re81zV77m7323p9+v/q313rePXt/c/e09nsoBQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYNFpkWciq9r7GyOrF4cBAJi1ayJrI+vb+7s6YwAAzIHb2te/ImdF1nXGAACYAwe1r9ms/dkdAABgvmwX2VAXAQAAAAAAAAAAAACA/79LI79GNkW+i3wf+SHyY+SnyM/t+B9bCAAAU7B35J82W7NL5PDIVaXZmy3fv2bJOwAAGMwNpWnAfqkHRtgY2bEuAgAwjENK07TdWg9sxa6R0+vijOW+cQAAU3Nm5IPIe5HjIwcuHZ64bNj+Ls3pBvPq9sjXpflb72hrOccu73Pe3ZVtDQBgcNmAnNC5f7RzPZTdS7/5bPMg/8bt2+unIzt1xgAApiKfdO3Wub+ncz2kzaVphtbXA3Pmo8g5bXauxgAApiK32MjGKQ9ef78aG1LOAXulNN/9eTU2Sj7xeqMs/lSZ8nOOjdzUXk9KrlrNzzuxHgAAmJa9IhdHvihLfx7NRuXohTcNZHVZ3k+jJ0WujXzVqeVK0pTN3Led+hmd69pLdWELTi1L57F15Ty2h+pi68K6AAAwrgNK85Sq65TSr9H4cCvJhQurFt860r2l2W9tOXIRQDoqcll7fUzkvvZ6lLPrQiWb130jD5fxm0oAgG2WT9U+qWr5c2JOqr8k8lg1NpR8SnZFXezp7fY1m8xsNlOu7jy5vd4v8nx7Pa5cLftme51P9LJh627lkY3cO6X5jq6LIjdXNQCAZXkxcnDk48hnkfvb+5SLEN5qr4eSzc8T7etyPRV5IXJ5ZENp/k97dsbXRj7t3PfxbuS30jRoC81efnbe/14W59vtU5onirXc4PeCuggAMGlXR+6OHFcPTNCXkf3r4gCyIR3CDqWZg3d9+e9cv/xZGABgUOdFnquLE3Ruaead9ZXnkJ5fF3u6sy5MUH72EaX5SfbQtpanMjiwHgBY0Q6LrKuLI2yK7FEXR3i2NN81zgKIbZUrSR8szZmpAAArUs77yu1D+joy8njkkXqgh1xQkf92Gta0r0+WZuNhB9UDACvWwp5r42bhaCgAAAaU2268XJrTDV6LvF4la6+24/m+3NQ2V33mKlAAAAAAAAAAAACYlNxwdpQ8Y/SbuggAwHzRsAEAzMAtpdlctg8NGwDADIxzILuGDQBgRvoeyL6xLgAAMB19DmS/LrI58kA9AADAcGZxIDsAAGOY5oHsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsJL9C0p6rXh2lYdpAAAAAElFTkSuQmCC>
[image15]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAaCAYAAADSbo4CAAABnElEQVR4Xu2VPShGYRTHDyKJJEUJUaIUG5OPspAwGaQkMViUwSpJYWBgEQODQSYfGZSyWCQfycTAIDLYJDLgfzrP6z733Ot9DffexfurX2/POaf7nu7zPOcSJUnyT0jRgaAohzfwFX4Z72Clya9a8Q94DTNNLhRqSP7sGWZY8RySxoZVPFQOSJrpM+ssuA9bfioiopOkkROS178HW10VEZEKb0maOYZd7nS0jJE0sq0TUZIGN+AbfIcF7rSHLTigg4pGeKaD8eBtWYP9cIHkrYy7KrzwIc7VQUUhSTN/gofUChwy6wr4CR9heqwobLiJJZI5YcM3ht9Kr4ozdXAR7uiERT6cIXkO18elCG6SbIWmnaQRv/2dhlXwXicsZklGwDLJBfClBz6QM7p5vDdZ+RH4YuW5dt3K58FJOGfFNCXm95JCHog8b2pJtuA3+CDz9ylbJ4KiHl7BYjhoYqPkPQtt8FzFAqUUHsF5cm7VKcnNs5ki//MXOhNqfQi7VSx0mmEHrIa7sAw+UeKBFzixrzRP0QuSsdDgpJMk5htB6EujfX4LTQAAAABJRU5ErkJggg==>
[image16]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAZCAYAAAAIcL+IAAAAnUlEQVR4XmNgGAUIoAbET4G4BF0CHZgA8QUgdkaXoB1IAeJ1QHwTiLPR5OAgCog7oOzJQPwSSQ4FHABiNij7EBCfR0hhB05A/B+IA9Al0EELEP8DYiF0CXRwDIjPoQuiAx4g/g3E3egS6MCTAeI+D3QJEPAGYgsoewIQfwJiLoQ0AoBM2AbEukD8BYhzUKUR4AQDxAOHgTgUTW5IAQBAKBmkXjz29QAAAABJRU5ErkJggg==>
[image17]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAABF0lEQVR4XmNgGAWjYBQMfuANxDuB+DaUDQNuQPwWiPmg/D1AvBchTRjIA/FWIGYG4vNAvB5JbjEQv0TiHwXif0DMgiSGF+QBsT0DxBKQxkIkuSdAvByJrwXEj5H4MFAJxBPRBZHBFSDejcRPBeL/QKyCJJYJxGFIfKIAyJsgg1qQxCYB8XckPgjsAGIOJL4ZA0QPKB54kcQxwF0gXgJlczNAfAIKJjGoWDIDxOUwwAbE3VD2TSA2QZLDACBXHAPi7QwQFzoxQCL0MhBvAuJahFIwAPlAFIiFgPgHlE914AvEJ9AFqQU6gLgPXZBa4DAQB6ILUgOAIvUbAyTsqQaigLgRiCOBeD+aHMWgiAFSzqwAYmk0uWEAAKcOLie/LXr3AAAAAElFTkSuQmCC>
[image18]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAAAzCAYAAAAq0lQuAAACcklEQVR4Xu3dy6vNURQH8O2ZlAkJSSgMlBhIBgYywUAxIWUmRXmEgRgzkORRYmYgMUHJQB6lRAZK8kiZGSj/gCRh7X6/k3V/Xud0lTP4fOrbWWvtc+94t3/3t28pAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPxsQuRcZzY2cqEzG6113QEAAP37Epmd+t2RLW29MHK1/NjAbYq8i+xq+z+ZEpnZ1vcik9MaAAADOBh5kPqbqa4ORT5E9rX92bTWr6WRW90hAAD9mV+aU7aeY6muTrazN5GJkRMjl39pTORi6sdF3qceAICkPoo82snGtP40cin1e1K9PLK1rW9EjkRmtH39Xvf37m/X5kTOtHVPPaUDAGAA0yOvU7+9/TyfZvmFhHpqNsjLCJ9TPS3yPPUAAPTheGlOzHrqiwFVPXGrlpRm05U3bZtT/Td5g7Y+cir1AAD/1dzS/M3WszTrPQ58FBmf5v/aovLjUeTavDCAZZHT3eEA7kTmRVanWT61AwD47/aWZtP2Nc2utJ+L06w63OlHo24S35ZmQzgrcm3k8kC2dQcDeBW5nPr6KNWVHgDA0HkZudvWOyIL2rqf+8uqupGrjyZ/l7wh6qk/8607BADg1+rGqb41WeV7y26nuq7fT/1o7Sw2bAAAfVsReVyaDdqayIsy8kLaSZGpkU9p9i+silwvzQncys4aAAAD2hB50h0CADA8Hpbmf3MCADCkPpbm8loAAIbMgdJcUju7uwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAkPkOBatPQlzHsNEAAAAASUVORK5CYII=>
[image19]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAaCAYAAACD+r1hAAAAxklEQVR4XmNgGH5gNhC/AeJWdAl8YA0Q/wdiTXQJXMCNAaKhEV0CF2AB4tdAfANdAh+YwQCxRR9dAhdwYIBoaEMTxwmYgPgZEN9Fl8AF2ID4DAPEFlM0OQzACsQbgHgmA0RDL6o0KgCF0FogngPl3wbix0DMCFeBBECKVwHxMQaIk0AAFOMgW2xgimCAGYhXAPFTIJZAEtdlgGiYgiQGBguB+AcQm6FLAMFVIH7BADEUDDihAjEwATSQDMT/gDgYXWIUDBwAAJ9PIdzPlIZYAAAAAElFTkSuQmCC>
[image20]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAAaCAYAAABVX2cEAAABDElEQVR4XmNgGAXDFzgD8S0gfg/E/4H4IKo0GJwF4n8MEPlvQDwbVRoTbAHiewwQDZZociCQDcTLgJgJXQIdsALxGSCOYIAYthZVGgymMEB8QRDYAPFkIGYG4vtA/BeIVVBUQCxjRxPDChqB2A/KzmWAuG4aQppBCoi3I/HxggNAzAtlcwHxGwZIQItAxeKBuAjKxgv4GCCGIYMmBojr6qD85UCsi5DGDfyBuB5NTBSIvwPxKyDmBuLLqNK4ASiWrNEFgWA6A8R1c4B4EZocTnAOiFnQBRkgsQmKVZCBMWhyWIEdEJ9GF0QCoPQGMkwCXQIZuAHxAwZEFnkCxPbICqDAnAGSlUbBKBhQAADIFjDhxd8YOAAAAABJRU5ErkJggg==>
[image21]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAE4AAAAaCAYAAAAZtWr8AAADS0lEQVR4Xu2XWahOURiGX/M8XZinCCUZylguSEhELiRy4VwqQ6IMGTrGJFy4I26EQpSZZMiYeSpzpExlDFHm9/X9+/j+dez//H/Yiv3Ue7Hf/e299lp7rW99C0hJSUn5vxhAHadOUnOCe0lRgSqmLlInqD1UOx9QAJWpudQV6jy1nqrm7g+iiqjmVFWqPbWCmuJiymQIdY9qRlWknlA9siKSYTl1iaqZuR5HPabql0TkRxXYoG+DvasWdYGa7mIWUF8D3YUNZF5osJ5Tw5x3mlrmrpNA3/GBGu28crCBW+S8fCimnlI1MteDYQOzLgqAxdynHlCXYW3UcffLZANsOntuUlsC708zHta5joF/hLoWeLmoTb2jVgfeSmSvIqWjInddEG2pL9RE52maf6T2OS8J1sAGrmXgb4d9o89PuRgJe4+WeS5m4xcGTtNTjTRxXveM5/9YEuyGtds48DXz5bcO/DiU4KOB2wSbsdpstBl4ZsHS0VbqGHUGtqTz4ir1mXrl9B7W8AQXF6JkrWSrRJ6v+urBHByGtdso8NV5+Z0DPw6lHsVfpxpmvBGw/NkrCiIzqaP4kdf6w1aaqouc1IMtAZ8wxV5Yw76RJDiC3zNwmkGKX+K88tQL2EBFNIWNgUebhX5yTnrCGpjsvErUG9i2rB0tSeKW6uaM3ybw41gFix8V+Ldhq0sbRRxasnrWp65SqPxQ0EDnabrKm+e8pIg63CrwtTnIz3dz0Lcrfmjg38j4LWCzTWWOdlrPIVhMt8DPYjgsqIPz1lKvUXbBqftKpucKUJ/vT8ajZK7v6Rr4OkGo0/kS/Xztrp47sBxWHZZvFXPAB8D6JF8DG4tyhoKi3KHcovpnUklEsmh5fKLGOE+p4xm12Hk6Ho1F6VwYoaOWNjmVG957S+3IXDeApaO6JREWo0lzynk/RQlTDxfBzog7qY1IPrd5dORS6RB1aAZsSfkkPhX2w/c7L0QxD2HLUkyDDaYvaVS2zIf1XcdMta383sXFxNIbVpKchdV0esHfRJ1QZ6Jv2oXSOa8f9ZJ6FPgh2vRuwQbwILJTklBbC2ExepcK/k5ZEf8o2oVTCkRLOaw/U/JAxa2WbEoB6Ii0NDRTUlJSUn6db5Hgyw0c/53xAAAAAElFTkSuQmCC>
[image22]: <data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAmwAAAAvCAYAAABexpbOAAAKCElEQVR4Xu3ceaxt1xzA8R9qbA0lxJgQiiBCzPND1VSJeQqCFMUz/iEk6I2qsWKKGpMaSoIaquapx9CaqZkYnqFtSIrWPLO+WXvl/M66Z59z7nnv9d6XfD/Jyl17PHuvPazfWWudGyFJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ2i53Lek/Jf2vpD+W9Kc0fe+03k7x6qjHd1S/YAd5YNTyO2mY/lhJP5ou3mu7SzpyyD+7pOPTsgPdpUv6TElX7Rdsg29FvY7X6Rcc4P5Q0uv7mUucVdLN+5nbYN1rcquSnlfSffsF+0F7Pq8W9fl8Vlp2t6jlL0lraQFG9uKSjunm7W8ENqs4LHZ2wIazYxqw3TRmX9rruFHKX76kS6Tpb6T8ge6eJd2ln7mN1gkOdrpXRv3Ss6pLlvSCfuY2Wvea8GVgo5+5D/yim+6fz0nKg/KXpLXMC9huW9Kbunn72+/7GSN4We/0gI0WiRaw4eEpv1WHxmzA1vtaP+MARqvunfqZ22jd4GAne0Vsft4XOTgu+C9vi6x7TTiPjX7mPnBuP6Mz6aYpf0laSw7YLlvSF4f8LYe/vGDOjNkXz09LOj9qq9iTSrpo1BfX9aMGKueU9OSSXlXSz0o6rm4WVynp5JI+HbOBxvejHgP7eHea31woarfie0t6bEwDNrb5csqTDhqmG7bLx/ar2cVxWtR9vDxqa0I7l9/G5nP5QEzPpXevqK1d74vZFjbKh9Q8saQzYtoydkSMnwfl8ueo3SitXP4dsxVP225fe2dJ/yzpdiWdUtKbo5YNx/H+ki4zXTVeWtJXhgQC1r9HXW9S0kuG+cvQZXXHNM29w312n6j7/m5axj3x9ahl+bhh3m2irs99QvphSXco6R0l/aSkBw3rLfKGqPf3iTENDl405DkPurvIt+dkzP1K+nBJn49pVxxdc6dHPRfK8tSSflnSQ0r6UtTj53qzf54RutTIt/Put2/3SmvRIX/4kOccnhLTZ7R5WUyf91VcrqTnp+mtPk+LrsnHY/1r0vAe+WrU99gilNFGP7NzSNT3HdeCY8TYtecYKIf/Dn+fGvU+7J/PScqD8u/fUZK0khawnTf8zRXRc0u6QpomELl41JdVe+nfZPj72pJ2DXkqoLacIK3lrxzTyhW8GJuxSoQuxbyMsTRHpekcsBBUzXsZ9sfWgoLcqnfdmH4O67d8PhfMO07KhMqnodKlMutRCeRxNLzsm3wefEY7j90x28JGpbKRpvdXwAaOYyPlGV+GH5f0qSF/dNSxOXhO1OMmQMjlROXcvgCMYflZ/cyowQoVP/LYq7z/x5d04yHP+n8d8qzf1ntYyo95Rkm3TtOs34IDnoMceObnZB7ugYauXuRr1VpmuJcfETUA5fkA461uMOTzF5t523OvtoCNe+vwIc9xv2XIt2cUBGDfKelKad4ip0XdJtvq8zR2TbDuNeH4+VLQLGtpXiVg43ls77t3RX3fgc8cu/btOjT98zlJeVCW+V0hSSvLLWwEU/ll9IOUB+tRIfDC7F+0BEuME8EDYrqcF2telwHlVPZfiNn99/trNmJ22ToBW39sbZxU/5ltbA/rt2X5XNBvg11Rvzk3YwHbCVErsibva9WA7VJxwQZstG61PK0m4LrRcoRPxrRsGwKofG4PjRr8L8L6tGb0zkx5AoULD/m8fwKGFw551m+tlznwfnDKj5lELd+G9bnXQevzWKU9Dy0tk5KOHaavHrVy53qRfj3M517mS0l27ajnQ6DCPYOx7RkTNRawEezMQ1Dyj1g+No0f+XB9e1t9nsauCfptepOYf00Icsm38qAFdZFVArZzUp5WxXZs/B279n3A1j+fk5RvON5lZS9Jm8wbw9bQJZex3j1ifsB2fEwrDrqD2vIrpvyeqF1ADd0j1xvybR1aY7KNmP2sPmCjO6ShMmiBTtYfG7+ORd7vxWLa4sX6bVk+F/TnjV0xG2yMBWyvi9ngK+8rnwfz23nQWkXrEeVCZUBr08awDGMB28lRuwznpWem9RZp17vlCSLAfUHADf72rTVcn3xutMIsC9jw9JKu1c3jeBsCiIsM+bx/Wvhahcr6rUxYv61H19u8a5dNoraWNqzPvQ7KPQfludKehxaya0TtAn9P1MHorVUyo6zaZ2R8Ni3QtLxhbHu6o1tAQ/B0+JBnn/k5aXbH8nLIaJ17TDdvq8/T2DXBsmOZxPxr0j+Xy6wSsJ2b8gx9aPvn79i1b62KDIVA/3xOUh6Uf/7SJkkrWxSw8S2QgKvhhcaLjxdmvw3fupcFbPwlkGgYD/OatAyfGP42fZcoXWe5W7V9c8eHolYUvf7YWqV23vAXBEXtc3ILW18x9OeNg0r6YJr+S9QxYD1aIvKYnbyvfB7Mb+fBuVJpUi6MW+orhGVdQXuD4xgL2FqlRSsOrR3NLWJzwPa2km6YpsdQ1nkMG3ILG5U9ZY28f8YP8blg/XnBwSoBGwEjZd2w/mFDnqCB8U0gaDxjyI85KeVbF1juJmU8JCgrnqfev2LzeMl52/OL7tbaRSB+xJBnn+y7R4C3rByyQ2N2DBu2+jyNXRMsO5axa8IYyvPT/ENSfp6DY3nAxr7b+47xly2AY/7YtW/n3IYL9M/nJOVB+fMcS9KW8M2YbgteSLz86Pbp0eXyvZI+m+ZRmbDN74Zpggv2wzQVOAELywlaGDBPni6La0YdiP3GqJUAXRCtdYbpz0X9hWqPFxxdcGz3tKj7YxAyqAxPiVpx8S24rwD2xOZjYzxNC6jYLxUK2/JSb+fCfvpzaYPA53W/EPieHnWMDhUr69GN1eP4v13SN7v5+TzYNp/Hb2JaLnyj/1tJH4065ov1WvfYvkRgzb4pq3bedPPdeciTbjasS9BNwMnxowVstCxNYjbAXuTImP2VKPcH+zm7pJ9H/XyuIwEaXaOUIWV59LA+YwTbsRFEsD551qfMyL99WHfMW6N2A3JO/XVgsP+JJT1hmL8rLevRmvqRqBV5q6BpWeFcaHGi4ue+oxWKH3cwP6MLt9dvDwJYnhuO69FRj2sS9Rll3+0ZbWgdy+e0DIHQMWl6T2zteVp0Tbhf9vaa8MWBlshHpnk9jpPjpUz4kjiG4LS971o3L24f49eelrVTowZ6fE57Pnle2/OZA07KX5KkHaFValt195h2r2n/oHWMAHFVtCwe28/U2ih/SZJ2BFoa1gnYaJW4fz9T+xTd0/nXnMswhq4NW9Deo/wlSdoR2kD5dcbqHBfLB/RrPYwje1Q/cwX88IUfPPAjCq2HH6HwP/HWKX9JkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkjTu/6rQqJGXFtIOAAAAAElFTkSuQmCC>
@@ -0,0 +1,261 @@
# Consigne d'utilisation du Registry OCI Gitea
## Objectif
Toutes les images Docker finales d'un projet doivent être stockées dans le registre OCI privé de Gitea afin de centraliser :
- le code source ;
- les versions applicatives ;
- les images Docker ;
- les déploiements ;
- l'historique des versions.
Le registre OCI officiel à utiliser est :
```text
git.maison43gil.com
```
---
# Règle générale
Une image Docker finale ne doit jamais être publiée sur :
- Docker Hub ;
- GitHub Container Registry ;
- Quay ;
- GitLab Registry ;
- tout autre registre externe.
Toutes les images doivent être publiées sur :
```text
git.maison43gil.com
```
---
# Convention de nommage
Le nom de l'image doit correspondre au nom du dépôt Git.
Exemple :
```text
Dépôt Git :
home_hub
Image OCI :
git.maison43gil.com/gilles/home_hub:latest
```
Autres exemples :
```text
git.maison43gil.com/gilles/design_system:latest
git.maison43gil.com/gilles/nano_metrics:latest
git.maison43gil.com/gilles/esp_jardin:latest
git.maison43gil.com/gilles/proxmenus:latest
```
---
# Tags à publier
Minimum :
```text
latest
```
Recommandé :
```text
latest
v1.0.0
v1.1.0
v2.0.0
```
Exemple :
```text
git.maison43gil.com/gilles/home_hub:latest
git.maison43gil.com/gilles/home_hub:v1.0.0
```
---
# Structure attendue d'un projet
```text
projet/
├── src/
├── Dockerfile
├── docker-compose.dev.yml
├── docker-compose.deploy.yml
├── README.md
├── .env.example
└── .gitea/
└── workflows/
└── build.yml
```
---
# Dockerfile
Le Dockerfile doit générer une image autonome et prête au déploiement.
L'image finale doit :
- être optimisée ;
- ne contenir que les composants nécessaires ;
- ne pas contenir de secrets ;
- être exécutable sans modification.
---
# Construction de l'image
Exemple :
```bash
docker build \
-t git.maison43gil.com/gilles/home_hub:latest .
```
---
# Publication de l'image
Authentification :
```bash
docker login git.maison43gil.com
```
Publication :
```bash
docker push \
git.maison43gil.com/gilles/home_hub:latest
```
---
# Déploiement
Le fichier de déploiement doit utiliser exclusivement l'image OCI publiée.
Exemple :
```yaml
services:
home_hub:
image: git.maison43gil.com/gilles/home_hub:latest
container_name: home_hub
restart: unless-stopped
```
Le déploiement ne doit pas reconstruire l'image.
Interdiction :
```yaml
build: .
```
dans les fichiers de production.
---
# Séparation développement / production
Développement :
```yaml
build: .
```
autorisé.
Production :
```yaml
image:
```
obligatoire.
Exemple :
```text
docker-compose.dev.yml
```
```yaml
services:
app:
build: .
```
Exemple :
```text
docker-compose.deploy.yml
```
```yaml
services:
app:
image: git.maison43gil.com/gilles/app:latest
```
---
# Processus standard
```text
Développement
Tests
docker build
docker push
Registry OCI Gitea
docker compose pull
docker compose up -d
```
---
# Consigne pour un agent IA
Lorsqu'un projet doit être conteneurisé :
1. Créer un Dockerfile.
2. Construire une image Docker finale.
3. Nommer l'image selon le dépôt Git.
4. Publier l'image dans le registre OCI Gitea.
5. Générer un docker-compose.deploy.yml utilisant uniquement l'image publiée.
6. Ne jamais utiliser Docker Hub comme destination finale.
7. Ne jamais utiliser `build:` dans les fichiers de production.
8. Toujours privilégier :
```text
git.maison43gil.com/gilles/<nom_du_projet>:latest
```
comme source officielle de déploiement.
Le registre OCI Gitea constitue la source unique de vérité pour toutes les images Docker de production.
@@ -0,0 +1,275 @@
# **Cahier des Charges & Instructions de Développement : "HomeHub"**
Ce document sert de spécification technique de référence et de fichier d'instructions (prompt system) pour le développement de **HomeHub**, une application d'organisation personnelle auto-hébergée (PWA) conçue pour être déployée sur Proxmox 9 (Debian 13\) et intégrée à un écosystème local (Gitea, Home Assistant, Agent Hermes via MCP).
## **1\. Brainstorming & Choix Technologiques**
### **1.1. Architecture Globale**
L'application doit être légère, extrêmement réactive sur mobile, et facile à maintenir/sauvegarder sur un serveur Proxmox.
| Brique | Option A : Monolithe Go \+ Svelte (SvelteKit) | Option B : Backend FastAPI (Python) \+ Frontend React/Vite (PWA) | Option C : Full TypeScript (Node.js/NestJS \+ React/Vite) |
| :---- | :---- | :---- | :---- |
| **Avantages** | \- Très faible empreinte RAM. \- Binaire unique facile à déployer. \- Rapidité de Svelte sur mobile. | \- **Idéal pour l'IA (Hermes/Ollama via Python)**. \- API auto-documentée (Swagger). \- Écosystème de traitement d'image robuste. | \- Partage de types TypeScript (DRY). \- SDK MCP officiel disponible en TS. \- Écosystème PWA extrêmement mature. |
| **Inconvénients** | \- Écosystème de librairies d'IA/MCP plus limité en Go. | \- Deux services à orchestrer (Backend/Frontend). | \- Consommation RAM légèrement plus élevée que Go. |
| **Décision** | | **Retenue** (avec Docker Compose pour Proxmox) | |
*Pourquoi l'Option B ?* L'intégration d'un agent **Hermes** pour l'analyse de photos (via Vision LLM) et l'implémentation de serveurs MCP (Model Context Protocol) s'alignent parfaitement avec l'écosystème Python (FastAPI).
### **1.2. Base de Données**
Le besoin mentionne "plusieurs bases SQL" pour séparer les domaines.
* **Option retenue** : Une seule instance de **PostgreSQL** (hébergée en conteneur) avec une séparation logique par **Schémas** (schema users, todos, shopping, notes, calendar, kanban). Cela simplifie grandement les sauvegardes (un seul pg\_dump), permet des requêtes de jointure complexes si nécessaire (ex: lier un Todo à une Note), tout en garantissant une isolation stricte.
### **1.3. Synchronisation Calendrier (Google / Apple)**
* **Apple Calendar (iOS)** : Utilisation d'un serveur **CalDAV** minimal intégré au backend pour que iOS puisse s'y abonner nativement, ou synchronisation bidirectionnelle via l'API iCloud.
* **Google Calendar** : Flux d'authentification OAuth2 standardisé avec un worker asynchrone pour la synchronisation des modifications.
## **2\. Architecture Technique de l'Application**
\[ Smartphone / Laptop \] \<--- HTTPS \---\> \[ Traefik / OPNsense \]
(Réseau local 10.0.0.0/22)
┌─────────────────────────────────┐
│ Proxmox 9 VM / LXC │
│ ┌───────────────────────────┐ │
│ │ Docker Compose │ │
│ │ │ │
│ │ \[Frontend React/PWA\] │ │
│ │ \[Backend FastAPI\] │ │
│ │ \[PostgreSQL\] │ │
│ │ \[Redis \- Queue/Cache\] │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
## **3\. Schéma de Base de Données (PostgreSQL \- Schémas Multiples)**
Voici la structure relationnelle cible pour guider le générateur de code :
\-- SCHEMA: auth (Gestion Utilisateurs)
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE auth.users (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password\_hash VARCHAR(255) NOT NULL,
display\_name VARCHAR(100),
created\_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT\_TIMESTAMP
);
\-- SCHEMA: todos (Gestion des tâches)
CREATE SCHEMA IF NOT EXISTS todos;
CREATE TABLE todos.lists (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
user\_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
title VARCHAR(100) NOT NULL,
color VARCHAR(7) DEFAULT '\#3B82F6',
is\_shared BOOLEAN DEFAULT FALSE
);
CREATE TABLE todos.items (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
list\_id UUID REFERENCES todos.lists(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
due\_date TIMESTAMP WITH TIME ZONE,
postponed\_count INT DEFAULT 0, \-- Permet de suivre le nombre de décalages
status VARCHAR(20) DEFAULT 'pending', \-- pending, completed, cancelled
priority VARCHAR(10) DEFAULT 'medium', \-- low, medium, high
created\_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT\_TIMESTAMP,
updated\_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT\_TIMESTAMP
);
\-- SCHEMA: shopping (Liste de courses)
CREATE SCHEMA IF NOT EXISTS shopping;
CREATE TABLE shopping.lists (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
user\_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
is\_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE shopping.items (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
list\_id UUID REFERENCES shopping.lists(id) ON DELETE CASCADE,
product\_name VARCHAR(150) NOT NULL,
quantity VARCHAR(50) DEFAULT '1',
category VARCHAR(50), \-- Épicerie, Fruits, etc. (pour tri en magasin)
is\_checked BOOLEAN DEFAULT FALSE,
frequency\_score INT DEFAULT 0, \-- Pour l'auto-complétion intelligente
last\_purchased\_at TIMESTAMP WITH TIME ZONE
);
\-- SCHEMA: notes (Pense-bête & Références)
CREATE SCHEMA IF NOT EXISTS notes;
CREATE TABLE notes.items (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
user\_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
tags VARCHAR(50)\[\], \-- Array de tags pour recherche ultra-rapide
metadata JSONB, \-- Ex: {"store": "Brico Depot", "reference": "X12-34"}
created\_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT\_TIMESTAMP,
updated\_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT\_TIMESTAMP
);
CREATE INDEX idx\_notes\_tags ON notes.items USING gin(tags);
CREATE INDEX idx\_notes\_fts ON notes.items USING gin(to\_tsvector('french', title || ' ' || content));
\-- SCHEMA: kanban (Moins urgent)
CREATE SCHEMA IF NOT EXISTS kanban;
CREATE TABLE kanban.boards (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
user\_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
title VARCHAR(100) NOT NULL
);
CREATE TABLE kanban.columns (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
board\_id UUID REFERENCES kanban.boards(id) ON DELETE CASCADE,
title VARCHAR(100) NOT NULL,
position INT NOT NULL
);
CREATE TABLE kanban.cards (
id UUID PRIMARY KEY DEFAULT gen\_random\_uuid(),
column\_id UUID REFERENCES kanban.columns(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
position INT NOT NULL,
todo\_item\_id UUID REFERENCES todos.items(id) ON DELETE SET NULL \-- Lien optionnel
);
## **4\. Spécifications Fonctionnelles détaillées (Briques Applicatives)**
### **Brique 1 : Calendrier & Synchronisation Bidirectionnelle**
* **Interne** : Calendrier natif stocké en base de données.
* **Externe** :
* **CalDAV** : Exposer un endpoint CalDAV (/api/caldav/) pour permettre l'abonnement direct depuis iOS (sans passer par un tiers).
* **Google Calendar API** : Intégrer un mécanisme d'OAuth2 par utilisateur pour synchroniser un calendrier Google spécifique vers HomeHub, et inversement.
* **API** : Endpoints REST (GET /api/calendar/events, POST /api/calendar/events) utilisables par des scripts tiers.
### **Brique 2 : Gestion des Todos (Focus Mobile-First)**
* **Saisie Rapide** : Formulaire d'ajout rapide accessible en 1 clic sur mobile avec autocomplétion intelligente (ex: écrire "Rendez-vous dentiste demain 14h" doit parser automatiquement la date et l'heure via NLP léger côté backend).
* **Visualisation et Edition Mobile** :
* Vue liste épurée avec boutons d'action rapide : "Reporter de 1 jour", "Reporter à la semaine prochaine", "Marquer en retard".
* Actions par glissement (Swipe left to postpone, Swipe right to complete).
### **Brique 3 : Liste de Courses (Focus Grande Surface)**
* **Mode Magasin** :
* Affichage optimisé avec grands composants tactiles (facile à cocher d'une seule main).
* Tri automatique des articles par rayon/catégorie (ex: mettre tous les légumes ensemble) pour optimiser le parcours en magasin.
* Écran de veille désactivé lors de l'affichage de la liste de courses active (via la *Wake Lock API* du navigateur).
* **IA & Analyse Prédictive (Hermes / Vision)** :
* **Auto-remplissage Hebdo** : Algorithme basé sur la fréquence d'achat historique. Si le "Lait" est coché toutes les semaines, il apparaît pré-rempli dans la suggestion de la semaine.
* **Analyse de Photo (Hermes)** : Endpoint POST /api/shopping/analyze-fridge qui prend une image du réfrigérateur/placard, l'envoie à l'agent Hermes (ou LLM Vision local comme Llama-3-Vision) et renvoie la liste des ingrédients manquants détectés sous forme de suggestions de liste de courses.
### **Brique 4 : Notes de Référence (Pense-bête)**
* **Recherche Puissante** : Recherche Full-Text (FTS) PostgreSQL sur le titre, le contenu et les métadonnées (ex: chercher "courroie" ou "motoculteur" doit sortir la note instantanément).
* **Structure Métadonnées** : Possibilité de lier des paires clé/valeur spécifiques (Magasin d'achat, Référence, Lien) à chaque note.
## **5\. Intégrations Spécifiques (Hermes, Gitea, MCP, Home Assistant)**
### **5.1. Serveur MCP (Model Context Protocol) intégré**
Le backend FastAPI doit embarquer un serveur MCP. Ce serveur expose des "outils" (tools) réutilisables par des agents IA externes (comme votre agent Hermes ou Claude) :
* get\_todos() : Liste les tâches urgentes.
* add\_todo(title, due\_date) : Crée un todo.
* add\_shopping\_item(product\_name) : Ajoute un élément à la liste de courses.
* search\_notes(query) : Recherche dans les pense-bêtes.
### **5.2. Connecteur Gitea (https://gitea.maison43.duckdns.org/)**
* Permettre de lier un tableau Kanban ou une liste de Todos à un dépôt Gitea.
* **Webhooks** : Endpoint /api/webhooks/gitea pour recevoir les notifications de tickets (issues) Gitea créés ou fermés et les synchroniser automatiquement avec le Kanban ou la liste de tâches HomeHub.
### **5.3. Skill Agent Hermes**
* Exposer une API REST documentée en OpenAPI v3 (/api/openapi.json) permettant à Hermes d'appeler l'application pour ajouter des tâches à la voix ou par commande textuelle.
### **5.4. Home Assistant (10.0.0.2:8123)**
* Exposer des capteurs via des webhooks Home Assistant.
* Exemple : Notifier Home Assistant du nombre de tâches en retard pour l'afficher sur un dashboard mural (via REST API ou MQTT).
## **6\. Consignes de Code et d'Implémentation (Pour l'Assistant IA)**
**IMPORTANT : Respecte scrupuleusement ces règles lors de la génération du code de l'application.**
### **6.1. Backend : FastAPI (Python)**
* **Structure du projet** :
backend/
├── app/
│ ├── api/ \# Endpoints divisés par domaines (auth, todos, shopping, notes, calendar, mcp)
│ ├── core/ \# Configuration, sécurité, base de données (SQLAlchemy)
│ ├── models/ \# Modèles SQLAlchemy (utilisant les schémas PostgreSQL spécifiés)
│ ├── schemas/ \# Schémas Pydantic pour la validation
│ ├── services/ \# Logique métier (synchro calendrier, intégration Hermes Vision)
│ └── main.py \# Point d'entrée de l'application
├── Dockerfile
└── requirements.txt
* **Base de données** : Utiliser SQLAlchemy 2.0 (async) \+ Alembic pour les migrations. Respecter scrupuleusement la séparation en schémas Postgres (auth, todos, etc.).
* **Sécurité** : JWT pour l'authentification. CORS configuré pour autoriser les requêtes du réseau local 10.0.0.0/22.
### **6.2. Frontend : React \+ Vite \+ Tailwind CSS \+ TypeScript**
* **PWA Obligatoire** :
* Configurer @vite-pwa/plugin dans Vite pour la gestion du Service Worker (mise en cache agressive des assets pour l'utilisation offline).
* Créer un manifest.json propre avec icônes adaptées pour iOS et Android.
* Gérer un état "Offline" propre dans l'UI (avertir l'utilisateur si les modifications sont stockées localement en attente de synchronisation).
* **UX Mobile-First** :
* Utiliser des composants tactiles adaptés (hauteur minimale de clic : 48px).
* Transitions fluides (Framermotion ou CSS transitions).
* Support du Swipe (glissement de doigt) pour les listes d'items (Todos, Courses).
* **Thème Visuel** :
* Interface moderne, épurée, avec un mode sombre automatique (détection système).
* Palette de couleurs : Tons neutres et élégants (Ardoise/Slate, Bleu Cobalt pour les actions).
### **6.3. Docker Compose (Prêt pour Proxmox 9\)**
Créer un fichier docker-compose.yml complet à la racine contenant :
1. Le service db (PostgreSQL 16\) avec persistance des données dans un volume.
2. Le service redis (pour les tâches asynchrones de synchronisation de calendrier).
3. Le service backend (FastAPI).
4. Le service frontend (Nginx servant le build React).
## **7\. Plan de Développement Itératif**
Pour guider la création de l'application étape par étape :
### **Phase 1 : Socle technique & Authentification**
1. Mise en place de l'environnement Docker, configuration de la base de données PostgreSQL avec ses schémas.
2. Création du backend FastAPI avec authentification utilisateur (multi-utilisateurs et gestion du partage optionnel).
3. Initialisation du frontend React PWA avec routage et configuration du Service Worker.
### **Phase 2 : Brique "Todos" & "Notes" (Focus Mobile)**
1. Développement des endpoints CRUD pour les Todos et Notes.
2. Création de l'interface mobile-first pour l'ajout et l'édition rapide de Todos (avec gestion des reports de date simplifiés).
3. Développement de la recherche Full-Text pour les notes avec filtres par tags.
### **Phase 3 : Brique "Liste de courses" & IA**
1. Implémentation de la vue "Magasin" interactive (mode d'écran actif permanent via Wake Lock).
2. Développement du service d'analyse de fréquences pour l'auto-remplissage hebdomadaire.
3. Création de l'intégration avec Hermes/LLM Vision pour l'analyse d'image (upload de photo de frigo).
### **Phase 4 : Calendrier & Synchronisation Apple/Google**
1. Développement du serveur CalDAV interne (ou abonnement webcal).
2. Mise en place du flux OAuth2 Google Calendar et du worker de synchronisation en arrière-plan.
### **Phase 5 : Intégration Écosystème (MCP, Gitea)**
1. Création du serveur MCP pour exposer les outils d'organisation à l'agent Hermes.
2. Mise en place des webhooks Gitea pour l'intégration Kanban.
+58
View File
@@ -121,6 +121,64 @@ docker compose up -d
# homehub.local/mcp → backend:8000/mcp (pour les agents IA)
```
## Déploiement via registre OCI Gitea
Les images finales sont publiées sur le registre OCI privé `git.maison43gil.com`
(source unique de vérité). Deux images sous le paquet `home_hub`, distinguées par tag :
| Image | Tag |
|-------|-----|
| Backend (FastAPI) | `git.maison43gil.com/gilles/home_hub:backend-latest` |
| Frontend (Nginx) | `git.maison43gil.com/gilles/home_hub:frontend-latest` |
### Construction & publication (manuel)
```bash
docker login git.maison43gil.com
# Backend
docker build -t git.maison43gil.com/gilles/home_hub:backend-latest ./backend
docker push git.maison43gil.com/gilles/home_hub:backend-latest
# Frontend
docker build -t git.maison43gil.com/gilles/home_hub:frontend-latest ./frontend
docker push git.maison43gil.com/gilles/home_hub:frontend-latest
```
> Le registre est derrière Nginx Proxy Manager : pour les gros blobs (image
> backend ~1.3 Go), régler `client_max_body_size 0;` dans l'onglet Advanced
> du proxy host `git.maison43gil.com` (sinon erreur `413 Payload Too Large`).
### Construction automatique (Gitea Actions)
`.gitea/workflows/build.yml` build et push les deux images :
- push sur `main` → tags `*-latest`
- tag git `vX.Y.Z` → tags `*-latest` + `*-X.Y.Z`
### Déploiement sur le serveur
```bash
# Sur le serveur de production
git pull # récupère docker-compose.deploy.yml + .env.example
cp .env.example .env # compléter les secrets (POSTGRES_PASSWORD, MCP_API_KEY…)
docker login git.maison43gil.com
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d
```
Le service `backend-migrate` applique automatiquement les migrations Alembic
(`alembic upgrade head`) avant le démarrage du backend. Aucune image n'est
reconstruite en production (pas de `build:`).
### Environnements
| Fichier | Usage | Images |
|---------|-------|--------|
| `docker-compose.yml` | Dev (build local) | `build:` |
| `docker-compose.dev.yml` | Override dev (HMR + `--reload`) | `build:` |
| `docker-compose.deploy.yml` | **Production** | `image:` (OCI Gitea) |
## Évolutions prévues
- **Phase 5** — Scan code-barres (zxing-js) + enrichissement catalogue via OpenFoodFacts
+7
View File
@@ -24,3 +24,10 @@
- integrer les articles de la liste transmise et de la liste boutique dans la liste magique
- scan code-barres (Phase 5)
- enrichissement catalogue depuis OpenFoodFacts (Phase 5)
- sur ecran shopping le bouton + si liste vide et si liste presente bouton edit (modifier le type de l'icon en fonction de la cisonstance)
- dans le bottom sheet edition listing si je supprime un article via '-' la case valid ne devient pas accessible
- amelioration du visuel du texte, pour question de simplicite je saisi toujours avec des minuscules, l'ors de la validation ou de l'ajout( verifier a quel moment ?) il faudrait mettre la 1ere lettre en majuscule
- la bootom sheet ne remonte pas toujours jusqu'au plus haut ? peut tu analyser ca
- dans la shopping liste ( c'est idem pour todo et notes) le bouton + masque une partie de la liste prevoir de mettre le bouton au dessus de la navbar ( a droite) et donc il faut prevoir de readapter la navbar: home/todo/courses/notes / espace libre pour le bouton + ( on peut l'appeler bouton action car il pourrais changer en fonction des evolution de l'app ?)
- en mode edition d'un article, on peut ajouter des tags qui peuvent etre utilise aussi pour la recherche d'article: je tape un mot dans le champ recherche de la liste ajout shopping et si ce mot apparait dans un tag de l'article, celui s'affiche : ex: ile flottante => tage creme dessert et ensuite lors de l'ajout d'un article, si je tape 'creme dessert' l'article ile flottante apparait car il comporte un tag 'creme dessert' autre exemple: tranche de lard a grille ( tag: lard, poitrine, ) si je tape poitrine il apparait dans la liste de recherche
- lors de la saisi dans le champ de recherche il arrive que le filtre de recherche disparraisse et la liste des elemnt ne soit pas visible, analysee le comportement et propose une amelioration ( pourquoi homehub.maison43gil.com apparait a l'ecran on peut le masquer?)![Description de l'image](images/image0.png) lors de la saisi dans le champ de recherche il arrive que le filtre de recherche disparraisse et la liste des elemnt ne soit pas visible, analysee le comportement et propose une amelioration ( pourquoi homehub.maison43gil.com apparait a l'ecran on peut le masquer?)
+2
View File
@@ -38,3 +38,5 @@
- **Différenciation mobile/laptop** : mobile = groupes par domaine + swipe ; laptop = tableau avec filtres date, colonnes domaines/priorité/statut
## En attente
- possibilite d'enchainer des todo ( parent/enfant
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
tests/
.env
.env.*
*.dump
.mypy_cache/
.ruff_cache/
+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc postgresql-client \
libpq-dev gcc postgresql-client ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
@@ -0,0 +1,30 @@
"""0061 - ajout colonne urls (JSONB) sur notes.items
Revision ID: 0061
Revises: 006
Create Date: 2026-05-30
Note : renumérotée 0061 (au lieu de 006) pour résoudre une collision avec
006_product_tags. Chaînée après product_tags : 005 -> 006 -> 0061 -> 007.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = '0061'
down_revision = '006'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'items',
sa.Column('urls', JSONB, nullable=True),
schema='notes',
)
def downgrade():
op.drop_column('items', 'urls', schema='notes')
@@ -0,0 +1,32 @@
"""007 - list_type sur shopping.lists, url/description/image_url sur list_items
Revision ID: 007
Revises: 0061
Create Date: 2026-05-30
"""
from alembic import op
import sqlalchemy as sa
revision = '007'
down_revision = '0061'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'lists',
sa.Column('list_type', sa.String(20), nullable=False, server_default='weekly'),
schema='shopping',
)
op.add_column('list_items', sa.Column('url', sa.Text, nullable=True), schema='shopping')
op.add_column('list_items', sa.Column('description', sa.Text, nullable=True), schema='shopping')
op.add_column('list_items', sa.Column('image_url', sa.String(255), nullable=True), schema='shopping')
def downgrade():
op.drop_column('list_items', 'image_url', schema='shopping')
op.drop_column('list_items', 'description', schema='shopping')
op.drop_column('list_items', 'url', schema='shopping')
op.drop_column('lists', 'list_type', schema='shopping')
+97 -48
View File
@@ -1,10 +1,15 @@
import asyncio
import os
import shutil
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -33,12 +38,14 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
uploads = settings.upload_path
photos = _dir_stats(uploads / "images" / "originals") if (uploads / "images" / "originals").exists() else {"count": 0, "size_bytes": 0}
audio = _dir_stats(uploads / "audio") if (uploads / "audio").exists() else {"count": 0, "size_bytes": 0}
video = _dir_stats(uploads / "videos") if (uploads / "videos").exists() else {"count": 0, "size_bytes": 0}
return {
"db_size_bytes": db_size,
"media": {
"photos": photos,
"audio": audio,
"video": video,
},
"counts": {
"notes": notes_count,
@@ -48,22 +55,14 @@ async def get_stats(session: AsyncSession = Depends(get_session)):
}
def _pg_env() -> dict:
def _pg_env() -> tuple[dict, object]:
url = urlparse(settings.database_url.replace("+asyncpg", ""))
env = os.environ.copy()
env["PGPASSWORD"] = url.password or ""
return env, url
@router.post("/backup")
async def create_backup():
backup_dir = settings.backup_path
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
filename = f"homehub_{timestamp}.dump"
filepath = backup_dir / filename
async def _pg_dump_to(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_dump", "-Fc",
@@ -71,48 +70,16 @@ async def create_backup():
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
"-f", str(filepath),
"-f", str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(500, f"Échec du backup : {stderr.decode()}")
stat = filepath.stat()
return {
"filename": filename,
"size": stat.st_size,
"created_at": datetime.now().isoformat(),
}
raise HTTPException(500, f"Échec du pg_dump : {stderr.decode()}")
@router.get("/backups")
async def list_backups():
backup_dir = settings.backup_path
if not backup_dir.exists():
return []
files = sorted(backup_dir.glob("*.dump"), key=lambda f: f.stat().st_mtime, reverse=True)
return [
{
"filename": f.name,
"size": f.stat().st_size,
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
}
for f in files
]
@router.post("/restore/{filename}")
async def restore_backup(filename: str):
# Sécurité : interdit les chemins relatifs
if "/" in filename or ".." in filename or not filename.endswith(".dump"):
raise HTTPException(400, "Nom de fichier invalide")
filepath = settings.backup_path / filename
if not filepath.exists():
raise HTTPException(404, "Fichier introuvable")
async def _pg_restore_from(path: Path) -> None:
env, url = _pg_env()
proc = await asyncio.create_subprocess_exec(
"pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges",
@@ -120,7 +87,7 @@ async def restore_backup(filename: str):
"-p", str(url.port or 5432),
"-U", url.username or "homehub",
"-d", (url.path or "/homehub").lstrip("/"),
str(filepath),
str(path),
env=env,
stderr=asyncio.subprocess.PIPE,
)
@@ -129,4 +96,86 @@ async def restore_backup(filename: str):
if proc.returncode not in (0, 1):
raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}")
return {"message": "Restauration réussie"}
@router.post("/backup")
async def download_backup():
"""Génère une archive .tar.gz contenant DB + médias, streamée au navigateur."""
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
archive_name = f"homehub_{timestamp}.tar.gz"
tmpdir = Path(tempfile.mkdtemp(prefix="homehub_backup_"))
dump_path = tmpdir / "db.dump"
archive_path = tmpdir / archive_name
await _pg_dump_to(dump_path)
def _build_archive() -> None:
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(dump_path, arcname="db.dump")
uploads = settings.upload_path
if uploads.exists():
tar.add(uploads, arcname="uploads")
await asyncio.to_thread(_build_archive)
def _cleanup() -> None:
shutil.rmtree(tmpdir, ignore_errors=True)
return FileResponse(
archive_path,
media_type="application/gzip",
filename=archive_name,
background=BackgroundTask(_cleanup),
)
@router.post("/restore")
async def upload_and_restore(file: UploadFile = File(...)):
"""Restaure depuis une archive .tar.gz uploadée (DB + médias)."""
if not file.filename or not file.filename.endswith((".tar.gz", ".tgz")):
raise HTTPException(400, "Format attendu : .tar.gz")
tmpdir = Path(tempfile.mkdtemp(prefix="homehub_restore_"))
try:
archive_path = tmpdir / "upload.tar.gz"
with archive_path.open("wb") as f:
while chunk := await file.read(1024 * 1024):
f.write(chunk)
extract_dir = tmpdir / "extract"
extract_dir.mkdir()
def _extract() -> None:
with tarfile.open(archive_path, "r:gz") as tar:
# Sécurité : refuser les chemins absolus ou contenant ..
for member in tar.getmembers():
if member.name.startswith("/") or ".." in Path(member.name).parts:
raise HTTPException(400, f"Chemin invalide dans l'archive : {member.name}")
tar.extractall(extract_dir, filter="data")
await asyncio.to_thread(_extract)
dump_path = extract_dir / "db.dump"
if not dump_path.exists():
raise HTTPException(400, "Archive invalide : db.dump introuvable")
await _pg_restore_from(dump_path)
uploads_src = extract_dir / "uploads"
if uploads_src.exists():
uploads_dst = settings.upload_path
uploads_dst.mkdir(parents=True, exist_ok=True)
def _sync_media() -> None:
for item in uploads_src.rglob("*"):
if not item.is_file():
continue
rel = item.relative_to(uploads_src)
dest = uploads_dst / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest)
await asyncio.to_thread(_sync_media)
return {"message": "Restauration réussie"}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from app.core.broadcaster import broadcaster
router = APIRouter()
@router.get("/stream")
async def event_stream():
return StreamingResponse(
broadcaster.subscribe(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
+523
View File
@@ -0,0 +1,523 @@
import json
import uuid
from datetime import datetime, timedelta, timezone, date as date_type
from decimal import Decimal
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from sqlalchemy import select, and_, text, or_
from sqlalchemy.orm import selectinload
from app.core.database import AsyncSessionLocal
from app.models.todos import TodoItem
from app.models.notes import NoteItem
from app.models.shopping import ShoppingList, ListItem, Product
_VALID_STATUSES = {"pending", "done", "cancelled"}
_VALID_PRIORITIES = {"low", "medium", "high"}
# La protection DNS rebinding (défaut FastMCP) valide le header Host contre
# ["127.0.0.1:*", "localhost:*", "[::1]:*"]. Elle est conçue contre les attaques
# navigateur sur des services localhost. Ici l'accès se fait depuis des agents
# externes (Hermes) via l'IP du serveur, et la vraie barrière est le Bearer token
# MCP_API_KEY (cf. MCPAuthMiddleware). On désactive donc cette protection devenue
# redondante et bloquante (sinon 421 "Invalid Host header" sur toute IP non-localhost).
mcp = FastMCP(
"HomeHub",
stateless_http=True,
streamable_http_path="/",
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False,
),
)
def _serialize(obj):
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date_type):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
raise TypeError(f"Type non sérialisable : {type(obj)}")
def _dumps(data) -> str:
return json.dumps(data, default=_serialize)
def _iso_week_label() -> str:
now = datetime.now(tz=timezone.utc)
iso = now.isocalendar()
return f"S{iso[1]} {iso[0]}"
# ── TODOS ──────────────────────────────────────────────────────────────────────
@mcp.tool()
async def get_todos(
status: str = "pending",
domain: str | None = None,
priority: str | None = None,
) -> str:
"""Liste filtrée des tâches. status: pending/done/cancelled. priority: low/medium/high."""
async with AsyncSessionLocal() as session:
conditions = [TodoItem.status == status]
if domain:
conditions.append(TodoItem.domains.contains([domain]))
if priority:
conditions.append(TodoItem.priority == priority)
stmt = (
select(TodoItem)
.where(and_(*conditions))
.order_by(TodoItem.due_date.asc().nulls_last(), TodoItem.created_at.desc())
.limit(100)
)
result = await session.execute(stmt)
items = result.scalars().all()
return _dumps([{
"id": item.id,
"title": item.title,
"status": item.status,
"priority": item.priority,
"domains": item.domains,
"tags": item.tags,
"due_date": item.due_date,
"postponed_count": item.postponed_count,
} for item in items])
@mcp.tool()
async def create_todo(
title: str,
due_date: str | None = None,
priority: str | None = None,
domain: str | None = None,
) -> str:
"""Crée une tâche. due_date en ISO 8601 (ex: 2025-06-01T10:00:00Z). priority: low/medium/high."""
if priority and priority not in _VALID_PRIORITIES:
return _dumps({"error": f"Priorité invalide : {priority}. Valeurs: low/medium/high"})
async with AsyncSessionLocal() as session:
parsed_due = None
if due_date:
try:
parsed_due = datetime.fromisoformat(due_date.replace("Z", "+00:00"))
except ValueError:
return _dumps({"error": f"Format de date invalide : {due_date}"})
item = TodoItem(
title=title,
due_date=parsed_due,
priority=priority or "medium",
domains=[domain] if domain else [],
)
session.add(item)
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"title": item.title,
"status": item.status,
"priority": item.priority,
"domains": item.domains,
"due_date": item.due_date,
})
@mcp.tool()
async def update_todo(
id: str,
title: str | None = None,
status: str | None = None,
priority: str | None = None,
) -> str:
"""Modifie une tâche. status: pending/done/cancelled. priority: low/medium/high."""
async with AsyncSessionLocal() as session:
try:
todo_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
item = await session.get(TodoItem, todo_id)
if not item:
return _dumps({"error": f"Tâche introuvable : {id}"})
if status is not None and status not in _VALID_STATUSES:
return _dumps({"error": f"Statut invalide : {status}. Valeurs: pending/done/cancelled"})
if priority is not None and priority not in _VALID_PRIORITIES:
return _dumps({"error": f"Priorité invalide : {priority}. Valeurs: low/medium/high"})
if title is not None:
item.title = title
if status is not None:
item.status = status
if priority is not None:
item.priority = priority
item.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"title": item.title,
"status": item.status,
"priority": item.priority,
})
@mcp.tool()
async def postpone_todo(id: str, days: int) -> str:
"""Reporte une tâche de N jours à partir de sa date d'échéance (ou maintenant si nulle)."""
async with AsyncSessionLocal() as session:
try:
todo_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
item = await session.get(TodoItem, todo_id)
if not item:
return _dumps({"error": f"Tâche introuvable : {id}"})
now = datetime.now(timezone.utc)
base = item.due_date if item.due_date else now
item.due_date = base + timedelta(days=days)
item.postponed_count += 1
item.updated_at = now
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"title": item.title,
"due_date": item.due_date,
"postponed_count": item.postponed_count,
})
@mcp.tool()
async def delete_todo(id: str) -> str:
"""Supprime une tâche définitivement."""
async with AsyncSessionLocal() as session:
try:
todo_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
item = await session.get(TodoItem, todo_id)
if not item:
return _dumps({"error": f"Tâche introuvable : {id}"})
await session.delete(item)
await session.commit()
return _dumps({"deleted": id})
# ── NOTES ──────────────────────────────────────────────────────────────────────
@mcp.tool()
async def search_notes(
query: str | None = None,
category: str | None = None,
tag: str | None = None,
) -> str:
"""Recherche de notes via FTS PostgreSQL (français). query cherche dans titre+contenu."""
async with AsyncSessionLocal() as session:
conditions = []
if category:
conditions.append(NoteItem.category == category)
if tag:
conditions.append(NoteItem.tags.contains([tag]))
if query:
conditions.append(
text(
"to_tsvector('french', coalesce(title,'') || ' ' || content)"
" @@ plainto_tsquery('french', :q)"
).bindparams(q=query)
)
stmt = (
select(NoteItem)
.options(selectinload(NoteItem.attachments))
.order_by(NoteItem.created_at.desc())
.limit(20)
)
if conditions:
stmt = stmt.where(and_(*conditions))
result = await session.execute(stmt)
notes = result.scalars().all()
return _dumps([{
"id": n.id,
"title": n.title,
"category": n.category,
"tags": n.tags,
"content_preview": n.content[:200] if n.content else "",
"created_at": n.created_at,
"attachment_count": len(n.attachments),
} for n in notes])
@mcp.tool()
async def get_note(id: str) -> str:
"""Retourne une note complète avec ses pièces jointes."""
async with AsyncSessionLocal() as session:
try:
note_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
stmt = (
select(NoteItem)
.where(NoteItem.id == note_id)
.options(selectinload(NoteItem.attachments))
)
result = await session.execute(stmt)
note = result.scalar_one_or_none()
if not note:
return _dumps({"error": f"Note introuvable : {id}"})
return _dumps({
"id": note.id,
"title": note.title,
"content": note.content,
"category": note.category,
"tags": note.tags,
"created_at": note.created_at,
"attachments": [{
"id": a.id,
"file_type": a.file_type,
"original_name": a.original_name,
} for a in note.attachments],
})
@mcp.tool()
async def create_note(
title: str,
content: str,
category: str | None = None,
tags: list[str] | None = None,
) -> str:
"""Crée une note avec titre, contenu markdown, catégorie optionnelle et tags."""
async with AsyncSessionLocal() as session:
note = NoteItem(
title=title,
content=content,
category=category,
tags=tags or [],
)
session.add(note)
await session.commit()
await session.refresh(note)
return _dumps({
"id": note.id,
"title": note.title,
"category": note.category,
"tags": note.tags,
"created_at": note.created_at,
})
@mcp.tool()
async def update_note(
id: str,
title: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
) -> str:
"""Modifie le titre, le contenu ou les tags d'une note."""
async with AsyncSessionLocal() as session:
try:
note_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
note = await session.get(NoteItem, note_id)
if not note:
return _dumps({"error": f"Note introuvable : {id}"})
if title is not None:
note.title = title
if content is not None:
note.content = content
if tags is not None:
note.tags = tags
await session.commit()
await session.refresh(note)
return _dumps({"id": note.id, "title": note.title, "tags": note.tags})
@mcp.tool()
async def delete_note(id: str) -> str:
"""Supprime une note et toutes ses pièces jointes (cascade)."""
async with AsyncSessionLocal() as session:
try:
note_id = uuid.UUID(id)
except ValueError:
return _dumps({"error": f"UUID invalide : {id}"})
note = await session.get(NoteItem, note_id)
if not note:
return _dumps({"error": f"Note introuvable : {id}"})
await session.delete(note)
await session.commit()
return _dumps({"deleted": id})
# ── SHOPPING ────────────────────────────────────────────────────────────────────
@mcp.tool()
async def get_shopping_lists() -> str:
"""Retourne toutes les listes de courses avec compteurs d'articles cochés/total."""
async with AsyncSessionLocal() as session:
stmt = (
select(ShoppingList)
.options(selectinload(ShoppingList.items))
.order_by(ShoppingList.created_at.desc())
)
result = await session.execute(stmt)
lists = result.scalars().all()
return _dumps([{
"id": lst.id,
"name": lst.name,
"status": lst.status,
"week_date": lst.week_date,
"created_at": lst.created_at,
"item_count": len(lst.items),
"checked_count": sum(1 for i in lst.items if i.is_checked),
} for lst in lists])
@mcp.tool()
async def get_active_shopping_list() -> str:
"""Retourne la première liste en statut 'draft' ou 'active' avec tous ses articles triés."""
async with AsyncSessionLocal() as session:
stmt = (
select(ShoppingList)
.where(ShoppingList.status.in_(["draft", "active"]))
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
.order_by(ShoppingList.status.asc(), ShoppingList.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lst = result.scalar_one_or_none()
if not lst:
return _dumps({"error": "Aucune liste active (statut draft ou active)"})
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
return _dumps({
"id": lst.id,
"name": lst.name,
"status": lst.status,
"item_count": len(lst.items),
"checked_count": sum(1 for i in lst.items if i.is_checked),
"items": [{
"id": item.id,
"name": item.custom_name or (item.product.name if item.product else "Article inconnu"),
"quantity": item.quantity,
"unit": item.unit,
"is_checked": item.is_checked,
} for item in sorted_items],
})
@mcp.tool()
async def search_products(q: str) -> str:
"""Recherche dans le catalogue produits par nom, description ou catégorie."""
if not q or not q.strip():
return _dumps({"error": "Paramètre q requis"})
async with AsyncSessionLocal() as session:
stmt = (
select(Product)
.where(
or_(
Product.name.ilike(f"%{q}%"),
Product.description.ilike(f"%{q}%"),
Product.category.ilike(f"%{q}%"),
)
)
.order_by(Product.frequency_score.desc())
.limit(20)
)
result = await session.execute(stmt)
products = result.scalars().all()
return _dumps([{
"id": p.id,
"name": p.name,
"brand": p.brand,
"category": p.category,
"default_unit": p.default_unit,
"frequency_score": p.frequency_score,
"last_purchased_at": p.last_purchased_at,
} for p in products])
@mcp.tool()
async def create_shopping_list(name: str | None = None) -> str:
"""Crée une liste de courses. name auto = semaine ISO courante si absent (ex: 'S22 2026')."""
async with AsyncSessionLocal() as session:
lst = ShoppingList(name=name or _iso_week_label())
session.add(lst)
await session.commit()
await session.refresh(lst)
return _dumps({
"id": lst.id,
"name": lst.name,
"status": lst.status,
"created_at": lst.created_at,
})
@mcp.tool()
async def add_shopping_item(
list_id: str,
name: str,
quantity: float | None = 1.0,
unit: str | None = None,
) -> str:
"""Ajoute un article à une liste de courses."""
async with AsyncSessionLocal() as session:
try:
lid = uuid.UUID(list_id)
except ValueError:
return _dumps({"error": f"UUID de liste invalide : {list_id}"})
lst = await session.get(ShoppingList, lid)
if not lst:
return _dumps({"error": f"Liste introuvable : {list_id}"})
item = ListItem(
list_id=lid,
custom_name=name,
quantity=Decimal(str(quantity)) if quantity is not None else None,
unit=unit,
)
session.add(item)
await session.commit()
await session.refresh(item)
return _dumps({
"id": item.id,
"name": item.custom_name,
"quantity": item.quantity,
"unit": item.unit,
"is_checked": item.is_checked,
})
@mcp.tool()
async def check_shopping_item(list_id: str, item_id: str) -> str:
"""Coche un article (marque comme acheté). Met à jour les stats du produit lié si présent."""
async with AsyncSessionLocal() as session:
try:
lid = uuid.UUID(list_id)
iid = uuid.UUID(item_id)
except ValueError:
return _dumps({"error": "UUID invalide (list_id ou item_id)"})
stmt = (
select(ListItem)
.where(ListItem.id == iid, ListItem.list_id == lid)
.options(selectinload(ListItem.product))
)
result = await session.execute(stmt)
item = result.scalar_one_or_none()
if not item:
return _dumps({"error": f"Article introuvable : {item_id} dans liste {list_id}"})
was_checked = item.is_checked
item.is_checked = True
if not was_checked and item.product:
product = item.product
today = date_type.today()
if product.last_purchased_at and product.last_purchased_at < today:
days = (today - product.last_purchased_at).days
if product.avg_interval_days is None:
product.avg_interval_days = Decimal(str(days))
else:
product.avg_interval_days = Decimal(str(
round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1)
))
product.last_purchased_at = today
product.frequency_score += 1
await session.commit()
return _dumps({"id": item.id, "is_checked": item.is_checked})
+3 -3
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, UploadFile, File, Query, HTTPException
from fastapi.responses import Response
from app.schemas.media import MediaUploadResponse
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES
router = APIRouter()
@@ -12,10 +12,10 @@ async def upload_media(
file: UploadFile = File(...),
context: str = Query(default="note", pattern="^(product|note|attachment)$"),
):
content_type = (file.content_type or "").lower()
content_type = (file.content_type or "").lower().split(";")[0].strip()
if content_type in ALLOWED_IMAGE_TYPES:
result = await save_image(file, context=context)
elif content_type in ALLOWED_AUDIO_TYPES:
elif content_type in ALLOWED_AUDIO_PREFIXES:
result = await save_audio(file)
else:
raise HTTPException(status_code=400, detail=f"Type de fichier non supporté : {content_type}")
+15 -9
View File
@@ -5,11 +5,12 @@ from sqlalchemy import select, text, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.broadcaster import broadcaster
from app.core.database import get_session
from app.core.redis import enqueue
from app.models.notes import NoteItem, NoteAttachment
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES
from app.services.media import save_image, save_audio, save_video, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES, ALLOWED_VIDEO_TYPES
router = APIRouter()
@@ -21,6 +22,7 @@ async def list_notes(
tag: str | None = Query(default=None),
has_photo: bool | None = Query(default=None),
has_audio: bool | None = Query(default=None),
has_video: bool | None = Query(default=None),
has_gps: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
@@ -55,15 +57,11 @@ async def list_notes(
notes = result.scalars().all()
if has_photo is not None:
notes = [
n for n in notes
if has_photo == any(a.file_type == "image" for a in n.attachments)
]
notes = [n for n in notes if has_photo == any(a.file_type == "image" for a in n.attachments)]
if has_audio is not None:
notes = [
n for n in notes
if has_audio == any(a.file_type == "audio" for a in n.attachments)
]
notes = [n for n in notes if has_audio == any(a.file_type == "audio" for a in n.attachments)]
if has_video is not None:
notes = [n for n in notes if has_video == any(a.file_type == "video" for a in n.attachments)]
return notes
@@ -77,6 +75,7 @@ async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_s
await session.commit()
await session.refresh(note, ["attachments"])
await enqueue("export_note_markdown", str(note.id))
broadcaster.broadcast("notes_changed")
return note
@@ -100,6 +99,7 @@ async def update_note(
await session.commit()
await session.refresh(note, ["attachments"])
await enqueue("export_note_markdown", str(note.id))
broadcaster.broadcast("notes_changed")
return note
@@ -111,6 +111,7 @@ async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_se
await session.delete(note)
await session.commit()
await enqueue("remove_note_markdown", str(note_id))
broadcaster.broadcast("notes_changed")
return Response(status_code=204)
@@ -137,6 +138,9 @@ async def add_attachment(
elif ct in ALLOWED_AUDIO_PREFIXES:
media = await save_audio(file)
file_type = "audio"
elif ct in ALLOWED_VIDEO_TYPES:
media = await save_video(file)
file_type = "video"
else:
raise HTTPException(400, f"Type non supporté : {file.content_type}")
@@ -151,6 +155,7 @@ async def add_attachment(
await session.commit()
await session.refresh(note, ["attachments"])
await enqueue("export_note_markdown", str(note_id))
broadcaster.broadcast("notes_changed")
return note
@@ -177,4 +182,5 @@ async def delete_attachment(
await session.delete(att)
await session.commit()
await enqueue("export_note_markdown", str(note_id))
broadcaster.broadcast("notes_changed")
return Response(status_code=204)
+37 -6
View File
@@ -1,4 +1,3 @@
# backend/app/api/shopping.py
import uuid
from datetime import datetime, timezone, date as date_type
from decimal import Decimal
@@ -8,6 +7,8 @@ from sqlalchemy import select, text, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.broadcaster import broadcaster
from app.core.database import get_session
from app.models.shopping import ShoppingList, ListItem, Product, Store
from app.schemas.shopping import (
@@ -26,6 +27,19 @@ def _iso_week_label() -> str:
return f"S{iso[1]} {iso[0]}"
async def _unique_week_label(session: AsyncSession) -> str:
base = _iso_week_label()
existing = (await session.execute(
select(ShoppingList.name).where(ShoppingList.name.like(f"{base}%"))
)).scalars().all()
if base not in existing:
return base
counter = 2
while f"{base} ({counter})" in existing:
counter += 1
return f"{base} ({counter})"
def _item_to_response(item: ListItem) -> ListItemResponse:
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
return ListItemResponse(
@@ -39,6 +53,9 @@ def _item_to_response(item: ListItem) -> ListItemResponse:
price_recorded=item.price_recorded,
carried_over=item.carried_over,
sort_order=item.sort_order,
url=item.url,
description=item.description,
image_url=item.image_url,
)
@@ -47,6 +64,7 @@ def _list_to_response(lst: ShoppingList) -> ShoppingListResponse:
return ShoppingListResponse(
id=lst.id,
name=lst.name,
list_type=lst.list_type,
store_id=lst.store_id,
week_date=lst.week_date,
status=lst.status,
@@ -166,12 +184,13 @@ async def create_shopping_list(
session: AsyncSession = Depends(get_session),
):
data = payload.model_dump()
if not data.get('name'):
data['name'] = _iso_week_label()
if not data.get('name') and data.get('list_type', 'weekly') == 'weekly':
data['name'] = await _unique_week_label(session)
lst = ShoppingList(**data)
session.add(lst)
await session.commit()
await session.refresh(lst, ["items"])
broadcaster.broadcast("shopping_changed")
return ShoppingListDetailResponse(
**_list_to_response(lst).model_dump(),
items=[],
@@ -214,6 +233,7 @@ async def update_shopping_list(
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(lst, field, value)
await session.commit()
broadcaster.broadcast("shopping_changed")
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
return ShoppingListDetailResponse(
**_list_to_response(lst).model_dump(),
@@ -228,6 +248,7 @@ async def delete_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depen
raise HTTPException(404, "Liste introuvable")
await session.delete(lst)
await session.commit()
broadcaster.broadcast("shopping_changed")
return Response(status_code=204)
@@ -245,6 +266,7 @@ async def add_item(
item = ListItem(list_id=list_id, **payload.model_dump())
session.add(item)
await session.commit()
broadcaster.broadcast("shopping_changed")
stmt = (
select(ListItem)
.where(ListItem.id == item.id)
@@ -294,6 +316,7 @@ async def update_item(
product.frequency_score += 1
await session.commit()
broadcaster.broadcast("shopping_changed")
await session.refresh(item, ["product"])
return _item_to_response(item)
@@ -311,6 +334,7 @@ async def delete_item(
raise HTTPException(404, "Article introuvable")
await session.delete(item)
await session.commit()
broadcaster.broadcast("shopping_changed")
return Response(status_code=204)
@@ -373,7 +397,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
result = await session.execute(query)
rows = result.mappings().all()
new_list = ShoppingList(name=_iso_week_label(), status="draft")
new_list = ShoppingList(name=await _unique_week_label(session), list_type="weekly", status="draft")
session.add(new_list)
await session.flush()
@@ -386,6 +410,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
))
await session.commit()
broadcaster.broadcast("shopping_changed")
stmt = (
select(ShoppingList)
@@ -417,8 +442,13 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
lst.status = "done"
unchecked = [i for i in lst.items if not i.is_checked]
if unchecked:
new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label())
if unchecked and lst.list_type == 'weekly':
new_list = ShoppingList(
store_id=lst.store_id,
list_type="weekly",
status="draft",
name=await _unique_week_label(session),
)
session.add(new_list)
await session.flush()
for item in unchecked:
@@ -433,6 +463,7 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
))
await session.commit()
broadcaster.broadcast("shopping_changed")
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
return ShoppingListDetailResponse(
**_list_to_response(lst).model_dump(),
+5
View File
@@ -5,6 +5,7 @@ from fastapi.responses import Response
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.broadcaster import broadcaster
from app.core.database import get_session
from app.models.todos import TodoItem
from app.schemas.todos import TodoCreate, TodoUpdate, PostponeRequest, TodoResponse
@@ -55,6 +56,7 @@ async def create_todo(
session.add(item)
await session.commit()
await session.refresh(item)
broadcaster.broadcast("todos_changed")
return item
@@ -74,6 +76,7 @@ async def update_todo(
await session.commit()
await session.refresh(item)
broadcaster.broadcast("todos_changed")
return item
@@ -87,6 +90,7 @@ async def delete_todo(
raise HTTPException(status_code=404, detail="Tâche introuvable")
await session.delete(item)
await session.commit()
broadcaster.broadcast("todos_changed")
return Response(status_code=204)
@@ -108,4 +112,5 @@ async def postpone_todo(
await session.commit()
await session.refresh(item)
broadcaster.broadcast("todos_changed")
return item
+36
View File
@@ -0,0 +1,36 @@
import asyncio
import json
from typing import AsyncGenerator
class EventBroadcaster:
def __init__(self):
self._queues: set[asyncio.Queue] = set()
async def subscribe(self) -> AsyncGenerator[str, None]:
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=32)
self._queues.add(queue)
try:
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=25)
yield msg
except asyncio.TimeoutError:
yield ": keepalive\n\n"
except asyncio.CancelledError:
pass
finally:
self._queues.discard(queue)
def broadcast(self, event_type: str, data: dict | None = None) -> None:
if not self._queues:
return
msg = f"event: {event_type}\ndata: {json.dumps(data or {})}\n\n"
for queue in list(self._queues):
try:
queue.put_nowait(msg)
except asyncio.QueueFull:
pass
broadcaster = EventBroadcaster()
+1
View File
@@ -10,6 +10,7 @@ class Settings(BaseSettings):
data_dir: str = "/data"
redis_url: str = "redis://redis:6379"
cors_origins: str = "http://localhost:3000"
mcp_api_key: str = ""
@property
def cors_origins_list(self) -> list[str]:
+29
View File
@@ -0,0 +1,29 @@
import hmac
import json
from starlette.types import ASGIApp, Receive, Scope, Send
from app.core.config import settings
class MCPAuthMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope.get("path", "").startswith("/mcp"):
headers = dict(scope.get("headers", []))
auth = headers.get(b"authorization", b"").decode()
expected = f"Bearer {settings.mcp_api_key}"
if not settings.mcp_api_key or not hmac.compare_digest(auth, expected):
body = json.dumps({"detail": "Unauthorized"}).encode()
await send({
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
(b"www-authenticate", b"Bearer"),
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
+8 -2
View File
@@ -4,24 +4,27 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.admin import router as admin_router
from app.api.events import router as events_router
from app.api.health import router as health_router
from app.api.media import router as media_router
from app.api.mcp_server import mcp
from app.api.notes import router as notes_router
from app.api.todos import router as todos_router
from app.api.shopping import router as shopping_router
from app.core.config import settings
from app.core.mcp_auth import MCPAuthMiddleware
from app.core.redis import init_redis, close_redis
from app.data.seed import run_seed
@asynccontextmanager
async def lifespan(app: FastAPI):
# Crée les dossiers data/ au démarrage
for subdir in ("uploads", "notes", "backup"):
Path(settings.data_dir, subdir).mkdir(parents=True, exist_ok=True)
await run_seed()
await init_redis()
yield
async with mcp.session_manager.run():
yield
await close_redis()
@@ -34,12 +37,15 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(MCPAuthMiddleware)
app.include_router(health_router, prefix="/api")
app.include_router(events_router, prefix="/api/events")
app.include_router(admin_router, prefix="/api/admin")
app.include_router(media_router, prefix="/api/media")
app.include_router(notes_router, prefix="/api/notes")
app.include_router(todos_router, prefix="/api/todos")
app.include_router(shopping_router, prefix="/api/shopping")
app.mount("/mcp", mcp.streamable_http_app())
app.mount("/media", StaticFiles(directory=str(settings.upload_path)), name="media")
+1
View File
@@ -18,6 +18,7 @@ class NoteItem(Base):
tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]"))
gps_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
gps_lon: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
urls: Mapped[list[dict] | None] = mapped_column(JSONB, nullable=True)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
+4
View File
@@ -63,6 +63,7 @@ class ShoppingList(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str | None] = mapped_column(String(100))
list_type: Mapped[str] = mapped_column(String(20), server_default="weekly")
store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
week_date: Mapped[date | None] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(20), server_default="draft")
@@ -86,6 +87,9 @@ class ListItem(Base):
price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
sort_order: Mapped[int | None] = mapped_column(Integer)
url: Mapped[str | None] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
image_url: Mapped[str | None] = mapped_column(String(255))
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
product: Mapped["Product | None"] = relationship("Product", lazy="select")
+28 -8
View File
@@ -1,7 +1,18 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
class NoteUrl(BaseModel):
label: str
url: str
@field_validator('url')
@classmethod
def validate_url(cls, v: str) -> str:
if not v.startswith(('http://', 'https://')):
raise ValueError('URL doit commencer par http:// ou https://')
return v
class AttachmentResponse(BaseModel):
@@ -19,8 +30,9 @@ class NoteCreate(BaseModel):
content: str
category: str | None = None
tags: list[str] = []
gps_lat: Decimal | None = None
gps_lon: Decimal | None = None
gps_lat: float | None = None
gps_lon: float | None = None
urls: list[NoteUrl] = []
class NoteUpdate(BaseModel):
@@ -28,8 +40,9 @@ class NoteUpdate(BaseModel):
content: str | None = None
category: str | None = None
tags: list[str] | None = None
gps_lat: Decimal | None = None
gps_lon: Decimal | None = None
gps_lat: float | None = None
gps_lon: float | None = None
urls: list[NoteUrl] | None = None
class NoteResponse(BaseModel):
@@ -39,7 +52,14 @@ class NoteResponse(BaseModel):
content: str
category: str | None
tags: list[str]
gps_lat: Decimal | None
gps_lon: Decimal | None
gps_lat: float | None
gps_lon: float | None
urls: list[NoteUrl] = []
created_at: datetime
attachments: list[AttachmentResponse]
@field_validator('urls', mode='before')
@classmethod
def coerce_urls(cls, v: object) -> object:
# Les notes antérieures à la migration 0061 ont urls=NULL en base.
return v or []
+18
View File
@@ -83,6 +83,9 @@ class ListItemCreate(BaseModel):
custom_name: str | None = None
quantity: Decimal | None = None
unit: str | None = None
url: str | None = None
description: str | None = None
image_url: str | None = None
@model_validator(mode='after')
def must_have_name(self) -> 'ListItemCreate':
@@ -96,6 +99,9 @@ class ListItemUpdate(BaseModel):
quantity: Decimal | None = None
unit: str | None = None
price_recorded: Decimal | None = None
url: str | None = None
description: str | None = None
image_url: str | None = None
class ListItemResponse(BaseModel):
@@ -110,13 +116,23 @@ class ListItemResponse(BaseModel):
price_recorded: Decimal | None
carried_over: bool
sort_order: int | None
url: str | None
description: str | None
image_url: str | None
class ShoppingListCreate(BaseModel):
name: str | None = None
list_type: Literal['weekly', 'project'] = 'weekly'
store_id: uuid.UUID | None = None
week_date: date | None = None
@model_validator(mode='after')
def project_requires_name(self) -> 'ShoppingListCreate':
if self.list_type == 'project' and not self.name:
raise ValueError('Une liste projet doit avoir un nom')
return self
class ShoppingListUpdate(BaseModel):
name: str | None = None
@@ -128,6 +144,7 @@ class ShoppingListResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str | None
list_type: str
store_id: uuid.UUID | None
week_date: date | None
status: str
@@ -140,6 +157,7 @@ class ShoppingListDetailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str | None
list_type: str
store_id: uuid.UUID | None
week_date: date | None
status: str
+68 -6
View File
@@ -1,3 +1,4 @@
import asyncio
import io
import uuid
from pathlib import Path
@@ -13,7 +14,8 @@ ALLOWED_IMAGE_TYPES = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml",
"image/heic", "image/heif",
}
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a"}
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a", "audio/aac"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-m4v", "video/3gpp"}
MAX_ORIG_SIZE = (500, 500)
@@ -48,7 +50,6 @@ async def save_image(file: UploadFile, context: str = "note") -> dict:
orig_path = orig_dir / f"{file_id}.webp"
img = Image.open(io.BytesIO(content)).convert("RGB")
# Redimensionne l'original à 500×500 max en conservant l'aspect ratio
img.thumbnail(MAX_ORIG_SIZE, Image.LANCZOS)
img.save(orig_path, "WEBP", quality=85)
@@ -78,18 +79,79 @@ async def save_audio(file: UploadFile) -> dict:
audio_dir = UPLOAD_DIR / "audio"
audio_dir.mkdir(parents=True, exist_ok=True)
ext = ".webm" if "webm" in (file.content_type or "") else ".m4a"
audio_path = audio_dir / f"{file_id}{ext}"
audio_path.write_bytes(await file.read())
raw_ext = ".ogg" if "ogg" in ct else (".webm" if "webm" in ct else ".m4a")
raw_path = audio_dir / f"{file_id}_raw{raw_ext}"
raw_path.write_bytes(await file.read())
# Transcode vers AAC/mp4 pour lecture universelle (Safari iOS, Chrome, Firefox)
out_path = audio_dir / f"{file_id}.m4a"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(raw_path),
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
if out_path.exists():
raw_path.unlink(missing_ok=True)
final_path = out_path
else:
# ffmpeg indisponible ou échec — conserver le fichier brut
final_path = audio_dir / f"{file_id}{raw_ext}"
raw_path.rename(final_path)
return {
"file_id": file_id,
"file_path": str(audio_path.relative_to(UPLOAD_DIR)),
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
"thumbnail_path": None,
"file_type": "audio",
}
async def save_video(file: UploadFile) -> dict:
ct = (file.content_type or "").lower().split(";")[0].strip()
if ct not in ALLOWED_VIDEO_TYPES:
raise HTTPException(status_code=400, detail=f"Format vidéo non supporté : {file.content_type}")
file_id = str(uuid.uuid4())
video_dir = UPLOAD_DIR / "videos"
video_dir.mkdir(parents=True, exist_ok=True)
content = await file.read()
if "webm" in ct:
# Transcode webm → H.264/mp4 pour Safari iOS
raw_path = video_dir / f"{file_id}_raw.webm"
raw_path.write_bytes(content)
out_path = video_dir / f"{file_id}.mp4"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(raw_path),
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
if out_path.exists():
raw_path.unlink(missing_ok=True)
final_path = out_path
else:
final_path = video_dir / f"{file_id}.webm"
raw_path.rename(final_path)
else:
# mp4/quicktime : déjà compatible, stockage direct
final_path = video_dir / f"{file_id}.mp4"
final_path.write_bytes(content)
return {
"file_id": file_id,
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
"thumbnail_path": None,
"file_type": "video",
}
def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None:
(UPLOAD_DIR / file_path).unlink(missing_ok=True)
if thumbnail_path:
+1
View File
@@ -8,5 +8,6 @@ pillow==11.1.0
python-multipart==0.0.20
httpx==0.28.0
arq==0.26.1
mcp>=1.9,<2.0
pytest==8.3.4
pytest-asyncio==0.24.0
+14
View File
@@ -5,6 +5,7 @@ from sqlalchemy.pool import NullPool
from app.main import app
from app.core import database
from app.core.config import settings
import app.api.mcp_server as mcp_server_module
def make_test_engine():
@@ -12,6 +13,19 @@ def make_test_engine():
return create_async_engine(settings.database_url, poolclass=NullPool)
@pytest.fixture
async def mcp_nullpool_session():
"""Remplace AsyncSessionLocal dans mcp_server par un sessionmaker NullPool
pour éviter les conflits d'event loop entre tests."""
engine = make_test_engine()
test_session_factory = async_sessionmaker(engine, expire_on_commit=False)
original = mcp_server_module.AsyncSessionLocal
mcp_server_module.AsyncSessionLocal = test_session_factory
yield
mcp_server_module.AsyncSessionLocal = original
await engine.dispose()
@pytest.fixture
async def db_session():
engine = make_test_engine()
+238
View File
@@ -0,0 +1,238 @@
import json
import uuid
import pytest
from sqlalchemy import delete
import app.api.mcp_server as mcp_server_module
from app.core.config import settings
from app.api.mcp_server import (
get_todos, create_todo, update_todo, postpone_todo, delete_todo,
)
from app.api.mcp_server import (
search_notes, get_note, create_note, update_note, delete_note,
)
from app.api.mcp_server import (
get_shopping_lists, get_active_shopping_list, search_products,
create_shopping_list, add_shopping_item, check_shopping_item,
)
from app.models.todos import TodoItem
from app.models.notes import NoteItem
from app.models.shopping import ShoppingList, ListItem
pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
@pytest.fixture(autouse=True)
async def cleanup_mcp_todos():
yield
# Utilise AsyncSessionLocal depuis le module mcp_server (patché par mcp_nullpool_session)
async with mcp_server_module.AsyncSessionLocal() as session:
await session.execute(delete(TodoItem).where(TodoItem.title.like("TEST_MCP_%")))
await session.execute(delete(NoteItem).where(NoteItem.title.like("TEST_MCP_%")))
await session.execute(delete(ShoppingList).where(ShoppingList.name.like("TEST_MCP_%")))
await session.commit()
async def test_get_todos_retourne_liste_json():
result = await get_todos(status="pending")
data = json.loads(result)
assert isinstance(data, list)
async def test_create_todo_outil_cree_une_tache():
result = await create_todo(title="TEST_MCP_todo_create")
data = json.loads(result)
assert data["title"] == "TEST_MCP_todo_create"
assert data["status"] == "pending"
assert data["priority"] == "medium"
# Cleanup
await delete_todo(id=str(data["id"]))
async def test_update_todo_outil():
created = json.loads(await create_todo(title="TEST_MCP_todo_update"))
result = await update_todo(id=str(created["id"]), status="done")
data = json.loads(result)
assert data["status"] == "done"
await delete_todo(id=str(created["id"]))
async def test_postpone_todo_outil():
created = json.loads(await create_todo(title="TEST_MCP_todo_postpone"))
result = await postpone_todo(id=str(created["id"]), days=3)
data = json.loads(result)
assert data["postponed_count"] == 1
await delete_todo(id=str(created["id"]))
async def test_delete_todo_outil():
created = json.loads(await create_todo(title="TEST_MCP_todo_delete"))
result = await delete_todo(id=str(created["id"]))
data = json.loads(result)
assert "deleted" in data
async def test_update_todo_id_invalide():
result = await update_todo(id="pas-un-uuid", title="x")
data = json.loads(result)
assert "error" in data
async def test_delete_todo_introuvable():
result = await delete_todo(id="00000000-0000-0000-0000-000000000000")
data = json.loads(result)
assert "error" in data
# ── NOTES ──────────────────────────────────────────────────────────────────────
async def test_search_notes_retourne_liste():
result = await search_notes(query="inexistant_xyz_abc_999")
data = json.loads(result)
assert isinstance(data, list)
assert data == []
async def test_search_notes_fts_trouve_par_mot_cle():
await create_note(title="TEST_MCP_note_fts", content="recette de cuisine française traditionnelle")
result = await search_notes(query="cuisine")
data = json.loads(result)
assert any(n["title"] == "TEST_MCP_note_fts" for n in data)
async def test_create_note_outil():
result = await create_note(title="TEST_MCP_note_create", content="Contenu de test MCP")
data = json.loads(result)
assert data["title"] == "TEST_MCP_note_create"
assert "id" in data
await delete_note(id=str(data["id"]))
async def test_get_note_outil():
created = json.loads(await create_note(title="TEST_MCP_note_get", content="Contenu get"))
result = await get_note(id=str(created["id"]))
data = json.loads(result)
assert data["title"] == "TEST_MCP_note_get"
assert data["content"] == "Contenu get"
assert "attachments" in data
await delete_note(id=str(created["id"]))
async def test_update_note_outil():
created = json.loads(await create_note(title="TEST_MCP_note_update", content="avant"))
result = await update_note(id=str(created["id"]), content="après")
data = json.loads(result)
assert "id" in data
await delete_note(id=str(created["id"]))
async def test_delete_note_outil():
created = json.loads(await create_note(title="TEST_MCP_note_delete", content="x"))
result = await delete_note(id=str(created["id"]))
data = json.loads(result)
assert "deleted" in data
async def test_get_note_introuvable():
result = await get_note(id="00000000-0000-0000-0000-000000000000")
data = json.loads(result)
assert "error" in data
# ── SHOPPING ──────────────────────────────────────────────────────────────────
async def test_get_shopping_lists_retourne_liste():
result = await get_shopping_lists()
data = json.loads(result)
assert isinstance(data, list)
async def test_create_shopping_list_outil():
result = await create_shopping_list(name="TEST_MCP_liste")
data = json.loads(result)
assert data["name"] == "TEST_MCP_liste"
assert data["status"] == "draft"
assert "id" in data
async def test_create_shopping_list_nom_auto():
result = await create_shopping_list()
data = json.loads(result)
# Le nom auto est au format "S{semaine} {année}" (ex: "S22 2026")
assert data["name"].startswith("S")
# Cleanup manuel — la liste auto n'a pas le préfixe TEST_MCP_
async with mcp_server_module.AsyncSessionLocal() as session:
await session.execute(
delete(ShoppingList).where(ShoppingList.id == uuid.UUID(data["id"]))
)
await session.commit()
async def test_add_shopping_item_outil():
liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_item"))
result = await add_shopping_item(
list_id=str(liste["id"]),
name="TEST_MCP_article",
quantity=2.0,
unit="kg",
)
data = json.loads(result)
assert data["name"] == "TEST_MCP_article"
assert data["is_checked"] is False
assert float(data["quantity"]) == 2.0
async def test_check_shopping_item_outil():
liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_check"))
article = json.loads(await add_shopping_item(
list_id=str(liste["id"]),
name="TEST_MCP_article_check",
))
result = await check_shopping_item(
list_id=str(liste["id"]),
item_id=str(article["id"]),
)
data = json.loads(result)
assert data["is_checked"] is True
async def test_search_products_retourne_liste():
result = await search_products(q="inexistant_xyz_abc_999")
data = json.loads(result)
assert isinstance(data, list)
assert data == []
async def test_get_active_shopping_list_structure():
result = await get_active_shopping_list()
data = json.loads(result)
assert isinstance(data, dict)
async def test_add_item_liste_invalide():
result = await add_shopping_item(list_id="pas-un-uuid", name="article")
data = json.loads(result)
assert "error" in data
# ── AUTH ──────────────────────────────────────────────────────────────────────
async def test_mcp_auth_rejet_sans_token(client):
"""Le middleware renvoie 401 si aucun header Authorization."""
resp = await client.get("/mcp")
assert resp.status_code == 401
async def test_mcp_auth_rejet_mauvais_token(client):
"""Le middleware renvoie 401 si le token est incorrect."""
resp = await client.get("/mcp", headers={"Authorization": "Bearer mauvais-token"})
assert resp.status_code == 401
async def test_mcp_auth_accepte_bon_token(client, monkeypatch):
"""Le middleware laisse passer avec le token correct."""
monkeypatch.setattr(settings, "mcp_api_key", "test-mcp-key-xyz")
resp = await client.get(
"/mcp",
headers={"Authorization": "Bearer test-mcp-key-xyz"},
)
assert resp.status_code != 401
View File
+253
View File
@@ -0,0 +1,253 @@
# mon design system — Package Smartphone (iOS / Android)
> Adaptation mobile complète de mon design system Gruvbox seventies.
> **45+ composants nommés**, optimisés tactile (hit targets ≥ 44px), animations fluides,
> dark + light, gestes nommés et testables, contrôle complet du clavier virtuel.
>
> Version 1.0 · iOS & Android
---
## 🚀 Démarrage rapide
```html
<!DOCTYPE html>
<html data-theme="dark">
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="tokens/tokens.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<!-- Base UI -->
<script type="text/babel" src="components/ui-kit.jsx"></script>
<!-- Mobile -->
<script type="text/babel" src="components/mobile-kit.jsx"></script>
<script type="text/babel" src="components/mobile-sheets.jsx"></script>
<script type="text/babel" src="components/mobile-gestures.jsx"></script>
<script type="text/babel" src="components/mobile-swipeable.jsx"></script>
<script type="text/babel" src="components/mobile-forms.jsx"></script>
<script type="text/babel" src="components/mobile-apps.jsx"></script>
<script type="text/babel">
// Ton app ici
</script>
</body>
</html>
```
---
## 📂 Contenu du package
```
package-smartphone/
├── README.md ← Ce fichier (guide humain)
├── consigne_mobile.md ← Brief pour agents IA
├── tokens/
│ ├── tokens.css ← Variables CSS web (dark + light)
│ ├── tokens.gnome.css ← Pour apps GTK
│ └── tokens.json ← Format générique
├── components/
│ ├── ui-kit.jsx ← Base (Button, Icon, Popup, gauges…)
│ ├── mobile-kit.jsx ← StatusBar, NavBar, TabBar, ActionCard…
│ ├── mobile-sheets.jsx ← BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh
│ ├── mobile-gestures.jsx ← useGesture, GestureZone (9 gestes nommés)
│ ├── mobile-swipeable.jsx ← SwipeableRow (swipe-to-reveal actions)
│ ├── mobile-forms.jsx ← TextInput, DateInput, Dropdown, Checkbox, Radio, MediaInsert…
│ └── mobile-apps.jsx ← Avatar+menu, Onboarding, Chat, Calendar, Maps, Scanner, Caméra, Files
└── examples/
├── exemple-mobile.html ← Vue d'ensemble + gestes interactifs
├── exemple-mobile-saisie.html ← Login, profil, formulaire complet, clavier virtuel
└── exemple-mobile-apps.html ← 10 écrans : onboarding, chat, calendrier, maps, scanner, caméra, fichiers, settings
```
Tous les exemples ont leurs composants copiés à côté pour fonctionner sans navigation `../`. Ouvre n'importe lequel directement dans le navigateur.
---
## 🧩 Tous les composants nommés
### Structure d'écran
- **`StatusBar`** — barre de statut iOS (heure, signal, batterie)
- **`NavBar`** — barre titre, mode compact ou large, bouton retour, actions à droite
- **`TabBar`** — barre d'onglets en bas (3-5 sections)
### Boutons & contrôles
- **`PrimaryButton`** — gros bouton plein largeur (primary, ghost, danger)
- **`IconButton`** — bouton icône seul avec tooltip
- **`Toggle`** — switch on/off
- **`SegmentedControl`** — sélecteur 2-4 options exclusives
- **`Button`** — bouton générique (depuis ui-kit)
### Listes
- **`ListSection / ListRow`** — liste de réglages iOS-style
- **`ActionCard`** — grosse tuile tactile pour grille d'apps
- **`SwipeableRow`** — ligne révélant des actions au swipe (Mail/Things)
- **`FileExplorer`** — liste de fichiers avec icônes par type + actions swipe
### Saisie
- **`FormField`** — wrapper avec label + hint + erreur
- **`TextInput`** — champ texte avec contrôle complet du clavier virtuel
- **`DateInput`** — picker date/heure/datetime natif mobile
- **`Dropdown`** — select avec sélecteur natif
- **`CheckboxItem`** — case à cocher avec label + description
- **`RadioGroup`** — options exclusives empilées
- **`MediaInsert`** — grille pour ajouter photo/image/vidéo/audio/fichier/GPS
- **`SearchBar`** — recherche tactile avec icône loupe
- **`FilterChips`** — chips horizontaux multi-sélection
### Fenêtres / Dialogues
- **`BottomSheet`** — feuille modale qui monte du bas (swipe-down pour fermer)
- **`ActionSheet`** — menu d'actions iOS (2-6 choix)
- **`AlertDialog`** — alerte centrée bloquante (confirmation destructive)
- **`Toast`** — notification éphémère 2.5s
- **`Popup`** — modale glassmorphism (depuis ui-kit)
### Identité utilisateur
- **`Avatar`** — bouton rond avec initiales (haut-droite de chaque écran)
- **`AvatarMenu`** — popup descendant : Profil / Paramètres / Aide / Déconnexion
- **`AvatarLogo`** — gros logo carré arrondi (login, profil)
- **`BiometricButton`** — Face ID / Touch ID
### Patterns d'app
- **`OnboardingSlider`** — slides + dots + suivant/passer
- **`ChatBubble`** — bulle message (envoyé/reçu, statut ✓ ✓✓)
- **`ChatComposer`** — barre d'envoi avec joindre + audio + send
- **`CalendarMonth`** — vue mois avec évènements
- **`MapView`** — carte avec pins colorés
- **`QrScannerView`** — viseur scanner avec ligne animée
- **`CameraView`** — viseur photo avec règle des tiers
### Gestes
- **`useGesture`** — hook qui détecte 9 gestes nommés
- **`GestureZone`** — zone interactive qui affiche le geste détecté
### Datavis (mobile-friendly)
- **`RadialGauge`**, **`BatteryGauge`** (compact ou standard), **`BigRadialGauge`**, **`Sparkline`**, **`LineChart`**, **`StatusLed`**
---
## ✋ Gestes nommés
9 gestes détectables par `useGesture()` :
| Nom | Geste | Usage typique |
|-------------|---------------------------------------|---------------------------------------|
| `Tap` | Pression rapide | Action principale (équiv. clic) |
| `DoubleTap` | Deux Tap rapprochés (< 300 ms) | Zoomer, liker |
| `LongPress` | Pression maintenue ≥ 500 ms | Menu contextuel, sélection |
| `SwipeLeft` | Glisser vers la gauche | Écran suivant, supprimer une ligne |
| `SwipeRight`| Glisser vers la droite | Écran précédent, archiver |
| `SwipeUp` | Glisser vers le haut | Plus de détails, fermer popup |
| `SwipeDown` | Glisser vers le bas | Rafraîchir, fermer BottomSheet |
| `Pan` | Glisser en continu | Déplacer un élément, scroll horizontal|
| `Pinch` | Écarter / rapprocher 2 doigts | Zoomer carte ou image |
---
## ⌨️ Clavier virtuel — paramètres
`TextInput` accepte 4 props clés pour contrôler le clavier mobile :
### `keyboard` (inputmode) — quel clavier afficher
- `text` — clavier standard
- `numeric` — pavé 0-9 (codes, OTP)
- `decimal` — pavé + virgule (prix, mesures)
- `tel` — pavé téléphone
- `email` — clavier texte + @ et . directs
- `url` — clavier texte + / et .com
- `search` — clavier standard, touche Entrée = "Rechercher"
- `none` — aucun clavier (picker custom)
### `autocomplete` — aide à la saisie système
- `email` · `tel` · `name` · `given-name` · `family-name`
- `address-line1` · `postal-code` · `country`
- `current-password` · `new-password`
- `one-time-code` (auto-lecture SMS sur iOS/Android !)
- `off`
### `enterHint` — texte de la touche Entrée
- `send` · `search` · `go` · `done` · `next` · `previous`
### `autocapitalize` — majuscules auto
- `sentences` · `words` · `characters` · `off`
### Combinaisons usuelles
| Cas | Réglages |
|--------------------|-------------------------------------------------------------------|
| Email | `keyboard="email" autocomplete="email" autocapitalize="off"` |
| Mot de passe | `type="password" autocomplete="current-password"` (ou `new-password`) |
| Code OTP SMS | `keyboard="numeric" autocomplete="one-time-code" maxLength={6}` |
| Téléphone | `keyboard="tel" autocomplete="tel"` |
| Recherche | `keyboard="search" enterHint="search"` |
| Prix / mesure | `keyboard="decimal"` |
| Adresse postale | `autocomplete="address-line1"`, puis `postal-code`, `country` |
| Texte libre | `autocapitalize="sentences" spellCheck={true}` |
---
## 🪟 Types de fenêtres mobile
| Type | Quand l'utiliser | Geste pour fermer |
|----------------|------------------------------------------------------------------|----------------------|
| `BottomSheet` | Action contextuelle, formulaire court, choix dans une liste | SwipeDown ↓ |
| `ActionSheet` | Menu d'actions (2-6 choix) sur un élément | Tap "Annuler" ou hors|
| `AlertDialog` | Confirmation destructive (suppression, déconnexion) | Volontairement bloquant|
| `Toast` | Feedback succès/erreur après une action | Auto 2.5s |
| `Popup` | Modale centrée plus large (form de config avancé) | ✕ ou clic extérieur |
---
## 📱 3 pages d'exemples (à ouvrir)
1. **`examples/exemple-mobile.html`** — vue d'ensemble, gestes interactifs, premières patterns
2. **`examples/exemple-mobile-saisie.html`** — login, profil, formulaire complet, clavier virtuel
3. **`examples/exemple-mobile-apps.html`** — 10 patterns d'app : onboarding, chat, calendrier, maps, scanner QR, caméra, fichiers, settings, avatar menu
Chaque page affiche un smartphone à gauche (basculable iOS/Android, sombre/clair) et la doc commentée à droite avec mini-visuels SVG sous chaque composant et écran.
---
## ⚙️ Paramétrage
Tout fonctionne avec les **mêmes tokens CSS** que le package desktop. Pour changer une couleur, édite `tokens/tokens.css` :
```css
:root[data-theme="dark"] {
--accent: #fe8019; /* Orange Gruvbox */
--bg-1: #2a231d; /* Fond app */
--ok: #4dbb26;
--warn: #fabd2f;
--err: #fb4934;
--blue: #3db0d1;
--purple: #c882c8;
}
```
Bascule de thème :
```js
document.documentElement.dataset.theme = 'light'; // ou 'dark'
```
---
## ✅ Checklist d'intégration
- [ ] Polices chargées (Inter, JetBrains Mono, Share Tech Mono)
- [ ] Font Awesome 6 chargé
- [ ] `tokens/tokens.css` chargé
- [ ] `data-theme="dark"` (ou "light") sur `<html>`
- [ ] React 18 + Babel chargés
- [ ] `ui-kit.jsx` chargé en premier
- [ ] Modules mobile chargés dans l'ordre : kit → sheets → gestures → swipeable → forms → apps
---
## 🤖 Pour les agents IA
Lire `consigne_mobile.md` pour les règles d'utilisation et conventions.
@@ -0,0 +1,659 @@
/* ============================================================
mobile-apps.jsx
Composants pour patterns d'app courants : avatar+menu,
onboarding, chat, calendrier, maps, recherche+filtres,
scanner QR, caméra, gestion fichiers.
============================================================ */
const { useState: uA, useRef: rA, useEffect: eA } = React;
/* ============================================================
Avatar — bouton rond utilisateur (initiales ou icône)
Nom système : Avatar
============================================================ */
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<button onClick={onClick} className="touch-press" style={{
width: size, height: size, borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
color: 'var(--bg-1)',
border: active ? '2px solid var(--accent)' : 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
WebkitTapHighlightColor: 'transparent',
}}>{initials}</button>
);
}
/* ============================================================
AvatarMenu — popup descendant depuis l'avatar
Nom système : AvatarMenu
Items : [{icon, label, onClick, danger}]
============================================================ */
function AvatarMenu({ open, onClose, name, email, items = [] }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.35)',
animation: 'fade-in .15s',
}}>
<style>{`
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
position: 'absolute', top: 56, right: 12,
width: 240,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
transformOrigin: 'top right',
}}>
<div style={{
padding: '14px 14px 12px',
display: 'flex', alignItems: 'center', gap: 10,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-2)',
}}>
<Avatar name={name} size={36} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
</div>
</div>
{items.map((it, i) => (
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
className="touch-press" style={{
width: '100%', minHeight: 44,
padding: '10px 14px',
background: 'transparent', border: 'none',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
display: 'flex', alignItems: 'center', gap: 10,
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
cursor: 'pointer', textAlign: 'left',
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
<span style={{ flex: 1 }}>{it.label}</span>
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
</button>
))}
</div>
</div>
);
}
/* ============================================================
OnboardingSlider — slides + dots + boutons suivant/passer
Nom système : OnboardingSlider
Cas : présentation d'une nouvelle app à l'utilisateur.
slides : [{icon, color, title, desc}]
============================================================ */
function OnboardingSlider({ slides, onFinish }) {
const [i, setI] = uA(0);
const isLast = i === slides.length - 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '14px 20px',
display: 'flex', justifyContent: 'flex-end',
}}>
<button onClick={onFinish} style={{
padding: '6px 12px', background: 'transparent', border: 'none',
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>Passer</button>
</div>
<div style={{
flex: 1, padding: '0 32px',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
textAlign: 'center',
}}>
<div style={{
width: 110, height: 110, borderRadius: 28,
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 28,
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
}}>
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
<Icon name={slides[i].icon} size={56} />
</div>
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
</div>
<div style={{ padding: '20px 24px 30px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, j) => (
<span key={j} onClick={() => setI(j)} style={{
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
background: i === j ? 'var(--accent)' : 'var(--border-3)',
transition: 'width .25s, background .2s',
cursor: 'pointer',
}} />
))}
</div>
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
onClick={() => isLast ? onFinish() : setI(i + 1)}>
{isLast ? 'Commencer' : 'Suivant'}
</PrimaryButton>
</div>
</div>
);
}
/* ============================================================
ChatBubble — bulle de message (envoyé/reçu)
Nom système : ChatBubble
============================================================ */
function ChatBubble({ text, time, me, status }) {
return (
<div style={{
display: 'flex',
justifyContent: me ? 'flex-end' : 'flex-start',
padding: '4px 14px',
}}>
<div style={{
maxWidth: '78%',
padding: '8px 12px',
background: me ? 'var(--accent)' : 'var(--bg-3)',
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
fontSize: 14, lineHeight: 1.4,
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
border: me ? 'none' : '1px solid var(--border-2)',
}}>
<div>{text}</div>
<div style={{
fontSize: 10,
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
marginTop: 4, textAlign: 'right',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: 4,
float: 'right',
}}>
{time}
{me && status === 'sent' && <span></span>}
{me && status === 'read' && <span></span>}
</div>
</div>
</div>
);
}
/* ============================================================
ChatComposer — barre d'envoi en bas (input + + + send)
Nom système : ChatComposer
============================================================ */
function ChatComposer({ onSend }) {
const [v, setV] = uA('');
return (
<div style={{
padding: '8px 10px 18px',
display: 'flex', alignItems: 'flex-end', gap: 8,
borderTop: '1px solid var(--border-2)',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px)',
}}>
<IconButton icon="plus" label="Joindre" size={36} />
<div style={{
flex: 1, minHeight: 36,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
}}>
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
placeholder="Message…"
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
}} />
</div>
{v ? (
<button onClick={() => { onSend && onSend(v); setV(''); }}
className="touch-press" style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="chevR" size={16} /></button>
) : (
<IconButton icon="terminal" label="Audio" size={36} />
)}
</div>
);
}
/* ============================================================
CalendarMonth — vue mois avec points sous les jours marqués
Nom système : CalendarMonth
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
============================================================ */
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
const today = new Date();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startDay = (first.getDay() + 6) % 7; // lundi = 0
const days = last.getDate();
const cells = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= days; d++) cells.push(d);
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 14px 12px',
}}>
<IconButton icon="chevL" label="Mois précédent" size={32} />
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
<IconButton icon="chevR" label="Mois suivant" size={32} />
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
padding: '0 8px',
}}>
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
<div key={i} style={{
textAlign: 'center', fontSize: 10,
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
fontWeight: 700, padding: '4px 0',
letterSpacing: '0.08em',
}}>{d}</div>
))}
{cells.map((d, i) => {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
const hasEvent = d && events.has(d);
return (
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
disabled={!d}
className="touch-press"
style={{
aspectRatio: '1',
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
border: 'none', borderRadius: 8,
fontFamily: 'var(--font-mono)', fontSize: 13,
fontWeight: isSel || isToday ? 700 : 500,
cursor: d ? 'pointer' : 'default',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
{d}
{hasEvent && (
<span style={{
position: 'absolute', bottom: 4, left: '50%',
transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
}}/>
)}
</button>
);
})}
</div>
</div>
);
}
/* ============================================================
MapView — placeholder visuel d'une carte avec pins
Nom système : MapView
============================================================ */
function MapView({ pins = [] }) {
return (
<div style={{
position: 'relative',
height: '100%', width: '100%',
background: 'var(--bg-2)',
overflow: 'hidden',
}}>
{/* fond carte stylisé */}
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
{/* routes */}
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
{/* zones */}
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
{/* fleuve */}
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
</svg>
{/* pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
background: p.color || 'var(--accent)',
transform: 'rotate(-45deg)',
border: '2px solid var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
}}>
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
</div>
{p.label && (
<div style={{
position: 'absolute', top: -28, left: '50%',
transform: 'translateX(-50%)',
padding: '3px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 6,
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
}}>{p.label}</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
FilterChips — barre de chips de filtre
Nom système : FilterChips
============================================================ */
function FilterChips({ value = [], onChange, options }) {
const toggle = (v) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
return (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'object' ? o.icon : null;
const active = value.includes(v);
return (
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
flex: '0 0 auto',
padding: '6px 12px',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', gap: 6,
cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={12} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
QrScannerView — viseur scanner code-barres / QR
Nom système : QrScannerView
============================================================ */
function QrScannerView({ onCapture }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000',
overflow: 'hidden',
}}>
{/* fake camera feed = grain animé */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
#15110c
`,
}}/>
{/* visée centrale */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 220, height: 220,
}}>
{/* 4 coins */}
{[
{ top: 0, left: 0, br: '4px 0 0 0' },
{ top: 0, right: 0, br: '0 4px 0 0' },
{ bottom: 0, left: 0, br: '0 0 0 4px' },
{ bottom: 0, right: 0, br: '0 0 4px 0' },
].map((c, i) => (
<div key={i} style={{
position: 'absolute', ...c, width: 28, height: 28,
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
borderRadius: c.br,
}}/>
))}
{/* ligne scan animée */}
<div style={{
position: 'absolute', left: 6, right: 6, height: 2,
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
animation: 'qr-scan 2.4s ease-in-out infinite',
}}/>
<style>{`@keyframes qr-scan {
0%, 100% { top: 6px; opacity: 1 }
50% { top: calc(100% - 8px); opacity: 0.7 }
}`}</style>
</div>
{/* overlay assombri hors visée */}
<div style={{
position: 'absolute', inset: 0,
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
pointerEvents: 'none',
}}/>
{/* texte */}
<div style={{
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
textAlign: 'center', color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>Pointe vers un QR code ou code-barres</div>
{/* boutons bas */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<IconButton icon="folder" label="Galerie" size={44} />
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
width: 70, height: 70, borderRadius: '50%',
background: 'var(--accent)', border: '4px solid #fff',
color: 'var(--bg-1)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="grid" size={26} /></button>
<IconButton icon="moon" label="Flash" size={44} />
</div>
</div>
);
}
/* ============================================================
CameraView — viseur appareil photo avec shutter rond
Nom système : CameraView
============================================================ */
function CameraView({ onShoot }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000', overflow: 'hidden',
}}>
{/* fake scene */}
<div style={{
position: 'absolute', inset: 0,
background: `
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
`,
backgroundBlendMode: 'overlay',
}}/>
{/* règle des tiers */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{[33.33, 66.66].map((p) => (
<React.Fragment key={p}>
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
</React.Fragment>
))}
</div>
{/* top bar */}
<div style={{
position: 'absolute', top: 20, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around',
padding: '0 16px',
}}>
{[
{ icon: 'moon', label: 'Flash' },
{ icon: 'clock', label: 'Minuteur' },
{ icon: 'grid', label: 'Grille' },
].map((b) => (
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
))}
</div>
{/* mode chips */}
<div style={{
position: 'absolute', bottom: 130, left: 0, right: 0,
display: 'flex', justifyContent: 'center', gap: 20,
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
<span style={{ opacity: 0.5 }}>Vidéo</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
<span style={{ opacity: 0.5 }}>Portrait</span>
</div>
{/* bottom controls */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<div style={{
width: 50, height: 50, borderRadius: 10,
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
border: '2px solid #fff',
}}/>
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
width: 76, height: 76, borderRadius: '50%',
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
cursor: 'pointer',
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
WebkitTapHighlightColor: 'transparent',
}}/>
<IconButton icon="refresh" label="Caméra avant" size={44} />
</div>
</div>
);
}
/* ============================================================
FileExplorer — liste fichiers/dossiers
Nom système : FileExplorer
============================================================ */
function FileExplorer({ items, onOpen, onAction }) {
const sizeFmt = (b) => {
if (b == null) return '';
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
};
const typeIcon = (t) => ({
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
})[t] || 'list';
const typeColor = (t) => ({
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
})[t] || 'var(--ink-3)';
return (
<div>
{items.map((it) => (
<SwipeableRow key={it.name}
onTap={() => onOpen && onOpen(it)}
leftActions={[
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => onAction && onAction('delete', it) },
]}
rightActions={[
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
onClick: () => onAction && onAction('rename', it) },
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
onClick: () => onAction && onAction('share', it) },
]}>
<div style={{
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 38, height: 38, borderRadius: 8,
background: 'var(--bg-1)',
border: `1px solid ${typeColor(it.type)}`,
color: typeColor(it.type),
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={typeIcon(it.type)} size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
</div>
</div>
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
</div>
</SwipeableRow>
))}
</div>
);
}
Object.assign(window, {
Avatar, AvatarMenu,
OnboardingSlider,
ChatBubble, ChatComposer,
CalendarMonth,
MapView,
FilterChips,
QrScannerView, CameraView,
FileExplorer,
});
@@ -0,0 +1,385 @@
/* ============================================================
mobile-forms.jsx
Composants de saisie mobile avec contrôle du clavier virtuel.
Tous nommés et exposés sur window.
============================================================ */
const { useState: uMF, useRef: rMF } = React;
/* ============================================================
FormField — wrapper standard pour un champ
Nom système : FormField
Affiche : label · description · le champ · message d'erreur/hint
============================================================ */
function FormField({ label, hint, error, required, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{label && (
<label style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>
{label}{required && <span style={{ color: 'var(--accent)', marginLeft: 4 }}>*</span>}
</label>
)}
{children}
{(error || hint) && (
<div style={{
fontSize: 12,
color: error ? 'var(--err)' : 'var(--ink-4)',
lineHeight: 1.4,
}}>{error || hint}</div>
)}
</div>
);
}
/* ============================================================
TextInput — champ texte avec contrôle complet du clavier virtuel
Nom système : TextInput
Props clavier virtuel (mobile uniquement) :
keyboard: 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'search' | 'decimal' | 'none'
autocomplete: 'name'|'email'|'tel'|'address-line1'|'postal-code'|'country'|
'given-name'|'family-name'|'current-password'|'new-password'|
'one-time-code'|'off'… (Web Authentication API)
autocapitalize: 'sentences' | 'words' | 'characters' | 'off'
spellCheck: bool
enterHint: 'send'|'search'|'go'|'done'|'next'|'previous' (texte de la touche Entrée)
pattern: regex de validation
============================================================ */
function TextInput({
value, onChange, placeholder, type = 'text', icon, trailing,
keyboard, autocomplete = 'off', autocapitalize = 'sentences',
spellCheck = false, enterHint, pattern, maxLength, multiline, rows = 4,
error,
}) {
const C = multiline ? 'textarea' : 'input';
const inputProps = {
value, onChange: (e) => onChange(e.target.value),
placeholder,
inputMode: keyboard,
autoComplete: autocomplete,
autoCapitalize: autocapitalize,
spellCheck,
enterKeyHint: enterHint,
pattern, maxLength,
rows: multiline ? rows : undefined,
type: !multiline ? type : undefined,
style: {
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: type === 'password' ? 'var(--font-mono)' : 'var(--font-ui)',
fontSize: 15,
padding: multiline ? '4px 0' : 0,
resize: multiline ? 'vertical' : undefined,
minHeight: multiline ? rows * 22 : undefined,
},
};
return (
<div style={{
display: 'flex', alignItems: multiline ? 'flex-start' : 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: `1px solid ${error ? 'var(--err)' : 'var(--border-2)'}`,
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{icon && <Icon name={icon} size={16} style={{ color: 'var(--ink-3)', flex: '0 0 auto', marginTop: multiline ? 4 : 0 }} />}
<C {...inputProps} />
{trailing}
</div>
);
}
/* ============================================================
DateInput — date picker natif mobile
Nom système : DateInput
============================================================ */
function DateInput({ value, onChange, mode = 'date' }) {
// mode : 'date' | 'datetime-local' | 'time' | 'month' | 'week'
const icons = { date: 'clock', 'datetime-local': 'clock', time: 'clock', month: 'clock', week: 'clock' };
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name={icons[mode]} size={16} style={{ color: 'var(--accent)' }} />
<input
type={mode}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: 'var(--font-mono)', fontSize: 15,
colorScheme: 'dark',
}}
/>
</div>
);
}
/* ============================================================
Dropdown — select natif stylisé
Nom système : Dropdown
============================================================ */
function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
position: 'relative',
}}>
<select value={value} onChange={(e) => onChange(e.target.value)} style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: value ? 'var(--ink-1)' : 'var(--ink-3)',
fontFamily: 'var(--font-ui)', fontSize: 15,
appearance: 'none', WebkitAppearance: 'none',
paddingRight: 24,
}}>
<option value="">{placeholder}</option>
{options.map((o) => (
typeof o === 'string'
? <option key={o} value={o}>{o}</option>
: <option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Icon name="chevD" size={14} style={{ color: 'var(--ink-3)', position: 'absolute', right: 14, pointerEvents: 'none' }} />
</div>
);
}
/* ============================================================
CheckboxItem — case à cocher (style iOS)
Nom système : CheckboxItem
Cas : oui/non sur une option, sélection multiple dans une liste
============================================================ */
function CheckboxItem({ checked, onChange, label, description }) {
return (
<label className="touch-press" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '12px 14px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: checked ? 'var(--accent)' : 'var(--bg-1)',
border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--bg-1)',
flex: '0 0 auto', marginTop: 1,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
transition: 'all .12s',
}}>
{checked && <Icon name="play" size={11} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{description}</div>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ display: 'none' }} />
</label>
);
}
/* ============================================================
RadioGroup — groupe d'options exclusives
Nom système : RadioGroup
============================================================ */
function RadioGroup({ value, onChange, options }) {
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
overflow: 'hidden',
}}>
{options.map((o, i) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const d = typeof o === 'object' ? o.description : null;
const active = value === v;
return (
<label key={v} className="touch-press" style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
background: 'var(--bg-1)',
}}>
{active && <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--accent)' }} />}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{l}</div>
{d && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{d}</div>}
</div>
<input type="radio" checked={active} onChange={() => onChange(v)} style={{ display: 'none' }} />
</label>
);
})}
</div>
);
}
/* ============================================================
MediaInsert — boutons "insérer..." pour image/vidéo/audio/GPS
Nom système : MediaInsert
Cas : ajouter une pièce jointe dans un formulaire mobile.
Note : utilise les API natives via <input type="file" accept="..." capture="..."/>
et navigator.geolocation pour le GPS.
============================================================ */
function MediaInsert({ onPick }) {
const items = [
{ id: 'photo', icon: 'grid', label: 'Photo', hint: 'Appareil photo', accept: 'image/*', capture: 'environment' },
{ id: 'image', icon: 'folder', label: 'Image', hint: 'Depuis la galerie', accept: 'image/*' },
{ id: 'video', icon: 'play', label: 'Vidéo', hint: 'Caméra ou galerie', accept: 'video/*', capture: 'environment' },
{ id: 'audio', icon: 'terminal', label: 'Audio', hint: 'Enregistrement vocal', accept: 'audio/*', capture: 'user' },
{ id: 'file', icon: 'download', label: 'Fichier', hint: 'Doc, PDF, autre', accept: '*' },
{ id: 'gps', icon: 'network', label: 'Position', hint: 'GPS du téléphone', special: true },
];
return (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
}}>
{items.map((it) => (
<label key={it.id} className="touch-press" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, padding: '14px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
color: 'var(--ink-1)',
cursor: 'pointer',
textAlign: 'center',
WebkitTapHighlightColor: 'transparent',
minHeight: 72,
}}>
<Icon name={it.icon} size={18} style={{ color: 'var(--accent)' }} />
<span style={{ fontSize: 12, fontWeight: 600 }}>{it.label}</span>
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>{it.hint}</span>
{!it.special && (
<input type="file" accept={it.accept} capture={it.capture}
onChange={(e) => onPick && onPick(it.id, e.target.files[0])}
style={{ display: 'none' }} />
)}
{it.special && (
<input type="button" onClick={() => {
if (!navigator.geolocation) { onPick && onPick('gps', { error: 'GPS indisponible' }); return; }
navigator.geolocation.getCurrentPosition(
(pos) => onPick && onPick('gps', { lat: pos.coords.latitude, lon: pos.coords.longitude }),
(err) => onPick && onPick('gps', { error: err.message }),
);
}} style={{ display: 'none' }} />
)}
</label>
))}
</div>
);
}
/* ============================================================
AvatarLogo — gros logo rond pour écran de connexion
Nom système : AvatarLogo
============================================================ */
function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
return (
<div style={{
width: size, height: size, borderRadius: size * 0.28,
background: `linear-gradient(135deg, var(--accent), color-mix(in oklch, var(--accent) 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: glow
? `inset 0 2px 0 rgba(255,255,255,0.25), 0 8px 24px var(--accent-glow), 0 4px 12px rgba(0,0,0,0.4)`
: 'inset 0 2px 0 rgba(255,255,255,0.25)',
margin: '0 auto',
}}>
<Icon name={icon} size={size * 0.45} />
</div>
);
}
/* ============================================================
BiometricButton — bouton biométrie (Face ID / Touch ID)
Nom système : BiometricButton
============================================================ */
function BiometricButton({ kind = 'face', label, onClick }) {
const lbl = label || (kind === 'face' ? 'Face ID' : 'Touch ID');
return (
<button onClick={onClick} className="touch-press" style={{
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
padding: '8px 14px',
background: 'transparent', border: 'none',
color: 'var(--accent)', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={kind === 'face' ? 'user' : 'play'} size={28} />
{lbl}
</button>
);
}
Object.assign(window, {
FormField, TextInput, DateInput, Dropdown,
CheckboxItem, RadioGroup, MediaInsert,
AvatarLogo, BiometricButton,
});
/* ============================================================
CATALOGUE KEYBOARD — pour la doc
============================================================ */
const KEYBOARD_CATALOG = [
{ name: 'text', desc: 'Clavier standard (lettres + chiffres).', usage: 'Tout texte libre, noms.' },
{ name: 'numeric', desc: 'Pavé numérique sans signe ni virgule.', usage: 'Codes PIN, OTP, références numériques.' },
{ name: 'decimal', desc: 'Pavé numérique avec virgule/point.', usage: 'Prix, mesures, montants.' },
{ name: 'tel', desc: 'Pavé téléphone avec + et formats.', usage: 'Numéros de téléphone.' },
{ name: 'email', desc: 'Clavier texte avec @ et . en accès direct.', usage: 'Adresses email.' },
{ name: 'url', desc: 'Clavier texte avec / et .com.', usage: 'URLs, liens.' },
{ name: 'search', desc: 'Clavier standard, touche Entrée = "Rechercher".', usage: 'Champs de recherche.' },
{ name: 'none', desc: 'Aucun clavier (utile avec un picker custom).', usage: 'Date picker custom, sélecteur de couleur, etc.' },
];
const AUTOCOMPLETE_CATALOG = [
{ name: 'name / given-name / family-name', usage: 'Nom complet, prénom, nom de famille' },
{ name: 'email', usage: 'Adresse email (autoremplie depuis le compte iOS/Android)' },
{ name: 'tel', usage: 'Numéro de téléphone' },
{ name: 'address-line1 / postal-code / country', usage: 'Adresse postale' },
{ name: 'current-password', usage: 'Mot de passe existant (Face ID/Touch ID propose le remplissage)' },
{ name: 'new-password', usage: 'Nouveau mot de passe (le gestionnaire propose d\'en générer un)' },
{ name: 'one-time-code', usage: 'Code OTP reçu par SMS (auto-lu sur iOS / Android)' },
{ name: 'off', usage: 'Désactive complètement les suggestions' },
];
const ENTER_HINT_CATALOG = [
{ name: 'send', usage: 'Envoyer un message (chat, email)' },
{ name: 'search', usage: 'Rechercher (résultat affiché en bas)' },
{ name: 'go', usage: 'Y aller (URL, action de navigation)' },
{ name: 'done', usage: 'Terminer la saisie et fermer le clavier' },
{ name: 'next', usage: 'Passer au champ suivant du formulaire' },
{ name: 'previous', usage: 'Revenir au champ précédent' },
];
Object.assign(window, { KEYBOARD_CATALOG, AUTOCOMPLETE_CATALOG, ENTER_HINT_CATALOG });
@@ -0,0 +1,286 @@
/* ============================================================
mobile-gestures.jsx
Détecteur de gestes nommés pour smartphone.
Chaque geste a un NOM SYSTÈME, et un composant de TEST.
============================================================ */
const { useState: uG, useRef: rG, useEffect: eG } = React;
/* ============================================================
useGesture — hook bas niveau qui détecte les gestes
Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher
au composant qui doit recevoir les gestes.
Callbacks supportés :
onTap tap simple (< 200ms, ne bouge pas)
onDoubleTap double-tap (deux tap rapides)
onLongPress long press (≥ 500ms sans bouger)
onSwipeLeft swipe vers la gauche
onSwipeRight swipe vers la droite
onSwipeUp swipe vers le haut
onSwipeDown swipe vers le bas
onPanStart début de glisser
onPan cours de glisser ({dx, dy})
onPanEnd fin de glisser
onPinch pincement ({scale, dx, dy})
============================================================ */
function useGesture(handlers = {}) {
const state = rG({
sx: 0, sy: 0, st: 0,
lx: 0, ly: 0, lt: 0,
moved: false, longPressTimer: null,
lastTap: 0, lastTapPos: null,
pinching: false, startDist: 0,
});
const reset = () => {
if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer);
};
const onTouchStart = (e) => {
const t = e.touches[0];
state.current.sx = t.clientX;
state.current.sy = t.clientY;
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.st = Date.now();
state.current.lt = Date.now();
state.current.moved = false;
// Pinch detection
if (e.touches.length === 2) {
const dx = e.touches[1].clientX - t.clientX;
const dy = e.touches[1].clientY - t.clientY;
state.current.startDist = Math.hypot(dx, dy);
state.current.pinching = true;
return;
}
// Long press
if (handlers.onLongPress) {
state.current.longPressTimer = setTimeout(() => {
if (!state.current.moved) {
handlers.onLongPress({ x: t.clientX, y: t.clientY });
state.current.moved = true; // empêche d'autres détections
}
}, 500);
}
handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY });
};
const onTouchMove = (e) => {
const t = e.touches[0];
const dx = t.clientX - state.current.sx;
const dy = t.clientY - state.current.sy;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
state.current.moved = true;
reset();
}
if (state.current.pinching && e.touches.length === 2) {
const px = e.touches[1].clientX - t.clientX;
const py = e.touches[1].clientY - t.clientY;
const dist = Math.hypot(px, py);
const scale = dist / state.current.startDist;
handlers.onPinch && handlers.onPinch({ scale, dx, dy });
return;
}
handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY });
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.lt = Date.now();
};
const onTouchEnd = (e) => {
reset();
const dx = state.current.lx - state.current.sx;
const dy = state.current.ly - state.current.sy;
const dt = Date.now() - state.current.st;
handlers.onPanEnd && handlers.onPanEnd({ dx, dy });
if (state.current.pinching) {
state.current.pinching = false;
return;
}
if (state.current.moved && dt < 500) {
const absX = Math.abs(dx), absY = Math.abs(dy);
if (absX > 50 || absY > 50) {
if (absX > absY) {
if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt });
else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt });
} else {
if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt });
else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt });
}
}
} else if (!state.current.moved && dt < 200) {
// Tap / DoubleTap
const now = Date.now();
const pos = { x: state.current.lx, y: state.current.ly };
const lp = state.current.lastTapPos;
if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) {
handlers.onDoubleTap && handlers.onDoubleTap(pos);
state.current.lastTap = 0;
} else {
handlers.onTap && handlers.onTap(pos);
state.current.lastTap = now;
state.current.lastTapPos = pos;
}
}
};
return { onTouchStart, onTouchMove, onTouchEnd };
}
/* ============================================================
GestureZone — zone tactile de test
Affiche le dernier geste détecté + un journal des gestes.
Toutes les actions sont nommées explicitement.
============================================================ */
function GestureZone({ label, accept = [] }) {
const [last, setLast] = uG(null);
const [log, setLog] = uG([]);
const [count, setCount] = uG({});
const [trail, setTrail] = uG(null);
const fire = (name, data) => {
setLast({ name, data, time: Date.now() });
setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5));
setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 }));
};
const hAll = {
onTap: () => fire('Tap'),
onDoubleTap: () => fire('DoubleTap'),
onLongPress: () => fire('LongPress'),
onSwipeLeft: () => fire('SwipeLeft'),
onSwipeRight: () => fire('SwipeRight'),
onSwipeUp: () => fire('SwipeUp'),
onSwipeDown: () => fire('SwipeDown'),
onPan: ({ dx, dy }) => setTrail({ dx, dy }),
onPanEnd: () => setTrail(null),
onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }),
};
// Filtre uniquement les handlers demandés
const h = accept.length === 0 ? hAll : Object.fromEntries(
Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase())))
);
const gesture = useGesture(h);
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
marginBottom: 12,
}}>
{label && (
<div style={{
padding: '10px 14px',
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--border-1)',
}}>{label}</div>
)}
<div {...gesture}
style={{
height: 200,
position: 'relative',
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: 'grab',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{/* indicateur central */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13,
color: 'var(--ink-3)', textAlign: 'center',
padding: 16, pointerEvents: 'none',
}}>
{last ? (
<div style={{
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}}>
{last.name}
{last.data && (
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
</div>
)}
</div>
) : (
<span>essaie un geste ici</span>
)}
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
{/* trail visuel pendant le pan */}
{trail && (
<div style={{
position: 'absolute',
top: '50%', left: '50%',
width: 14, height: 14,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--accent-glow)',
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
pointerEvents: 'none',
}} />
)}
</div>
{/* Journal */}
{log.length > 0 && (
<div style={{
padding: '8px 14px 10px',
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-1)',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--ink-3)',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>journal</span>
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
</div>
{log.map((l, i) => (
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
</div>
))}
</div>
)}
</div>
);
}
/* Catalogue des gestes — utile pour affichage + onglet d'aide */
const GESTURE_CATALOG = [
{ name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' },
{ name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' },
{ name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' },
{ name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' },
{ name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' },
{ name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' },
{ name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' },
{ name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' },
{ name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' },
];
Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });
@@ -0,0 +1,407 @@
/* ============================================================
mobile-kit.jsx
Composants mobile-first du design system.
Tous nommés explicitement et exposés sur window.
Tactile-ready : hit targets ≥ 44px, animations fluides,
pas de hover, feedback au touch.
============================================================ */
const { useState: uM, useRef: rM, useEffect: eM } = React;
/* ============================================================
StatusBar — barre de statut iOS-like (en haut de l'écran)
Nom système : StatusBar
Usage : décor en haut de toute page mobile.
============================================================ */
function StatusBar({ time = '14:02', battery = 78, signal = 4 }) {
return (
<div style={{
height: 44, flex: '0 0 auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 22px',
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
color: 'var(--ink-1)',
}}>
<span>{time}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* signal bars */}
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1.5 }}>
{[1, 2, 3, 4].map((b) => (
<span key={b} style={{
width: 3, height: 3 + b * 2, borderRadius: 1,
background: b <= signal ? 'var(--ink-1)' : 'var(--ink-4)',
}} />
))}
</span>
<Icon name="network" size={13} />
{/* battery */}
<span style={{
width: 24, height: 11, borderRadius: 3,
border: '1px solid var(--ink-1)',
position: 'relative', marginLeft: 2,
}}>
<span style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${battery / 100})`,
background: battery < 20 ? 'var(--err)' : 'var(--ink-1)',
borderRadius: 1,
}} />
<span style={{
position: 'absolute', right: -3, top: 3, bottom: 3,
width: 2, background: 'var(--ink-1)',
borderRadius: '0 1px 1px 0',
}} />
</span>
</span>
</div>
);
}
/* ============================================================
NavBar — barre de navigation en haut (titre + actions)
Nom système : NavBar
Usage : titre d'écran avec retour optionnel à gauche, actions à droite.
============================================================ */
function NavBar({ title, subtitle, onBack, right, large }) {
return (
<div style={{
flex: '0 0 auto',
padding: large ? '8px 16px 16px' : '8px 12px',
display: 'flex', flexDirection: 'column', gap: 4,
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderBottom: '1px solid var(--border-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', minHeight: 36, gap: 8 }}>
{onBack && (
<button onClick={onBack} style={{
width: 36, height: 36, borderRadius: 8,
background: 'transparent', border: 'none',
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>
<Icon name="chevL" size={20} />
</button>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{!large && (
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', textAlign: onBack ? 'center' : 'left' }}>
{title}
</div>
)}
</div>
{right && <div style={{ display: 'flex', gap: 4 }}>{right}</div>}
</div>
{large && (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 32, fontWeight: 700, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{subtitle}</div>}
</div>
)}
</div>
);
}
/* ============================================================
TabBar — barre d'onglets en bas (iOS/Android)
Nom système : TabBar
Usage : navigation principale entre 3-5 sections de l'app.
============================================================ */
function TabBar({ items, active, onSelect }) {
return (
<div style={{
flex: '0 0 auto',
display: 'flex', justifyContent: 'space-around', alignItems: 'stretch',
padding: '6px 8px 18px',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderTop: '1px solid var(--border-2)',
}}>
{items.map((it) => {
const isActive = active === it.id;
return (
<button key={it.id} onClick={() => onSelect(it.id)} style={{
flex: 1, minHeight: 50,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 3, padding: 0,
background: 'transparent', border: 'none',
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
cursor: 'pointer',
transition: 'color .2s, transform .12s',
transform: isActive ? 'translateY(-1px)' : 'translateY(0)',
}}>
<Icon name={it.icon} size={22} />
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.04em', textTransform: 'uppercase',
fontWeight: isActive ? 700 : 500,
}}>{it.label}</span>
</button>
);
})}
</div>
);
}
/* ============================================================
ListRow — ligne d'une liste réglages (style iOS)
Nom système : ListRow
Usage : option dans une liste de réglages. ≥ 44px de hauteur.
============================================================ */
function ListRow({ icon, iconColor, label, value, right, onClick, danger }) {
const isInteractive = !!onClick;
const Tag = isInteractive ? 'button' : 'div';
return (
<Tag onClick={onClick} style={{
width: '100%',
minHeight: 52,
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: 'transparent',
border: 'none', borderBottom: '1px solid var(--border-1)',
color: danger ? 'var(--err)' : 'var(--ink-1)',
cursor: isInteractive ? 'pointer' : 'default',
textAlign: 'left',
transition: 'background .12s',
WebkitTapHighlightColor: 'transparent',
}}
onTouchStart={isInteractive ? (e) => e.currentTarget.style.background = 'var(--bg-3)' : undefined}
onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
{icon && (
<span style={{
width: 30, height: 30, borderRadius: 7,
background: iconColor || 'var(--bg-4)',
color: iconColor ? 'var(--bg-1)' : 'var(--ink-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
}}>
<Icon name={icon} size={15} />
</span>
)}
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
{value && <span style={{ fontSize: 14, color: 'var(--ink-3)' }}>{value}</span>}
{right === undefined && onClick && <Icon name="chevR" size={14} style={{ color: 'var(--ink-3)' }} />}
{right}
</Tag>
);
}
/* ============================================================
ListSection — groupe de ListRow avec titre
Nom système : ListSection
============================================================ */
function ListSection({ title, hint, children }) {
return (
<div style={{ marginBottom: 18 }}>
{title && (
<div style={{
padding: '0 16px 6px',
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>{title}</div>
)}
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
margin: '0 12px',
overflow: 'hidden',
boxShadow: 'var(--shadow-1)',
}}>{children}</div>
{hint && (
<div style={{
padding: '6px 16px 0', fontSize: 12, color: 'var(--ink-3)',
lineHeight: 1.4,
}}>{hint}</div>
)}
</div>
);
}
/* ============================================================
ActionCard — grosse carte d'action tactile
Nom système : ActionCard
Usage : actions principales sur écran d'accueil.
============================================================ */
function ActionCard({ icon, iconColor, title, subtitle, value, onClick, badge }) {
return (
<button onClick={onClick} className="touch-press" style={{
flex: 1, minWidth: 0, minHeight: 110,
padding: 14,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--ink-1)',
textAlign: 'left',
display: 'flex', flexDirection: 'column', gap: 6,
cursor: 'pointer',
boxShadow: 'var(--tile-3d)',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 38, height: 38, borderRadius: 9,
background: `linear-gradient(135deg, ${iconColor || 'var(--accent)'}, color-mix(in oklch, ${iconColor || 'var(--accent)'} 60%, black))`,
color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
}}>
<Icon name={icon} size={18} />
</span>
<span style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2 }}>{title}</span>
{subtitle && <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{subtitle}</span>}
{value && (
<span className="mono" style={{ fontSize: 22, fontWeight: 700, marginTop: 'auto' }}>{value}</span>
)}
{badge && (
<span style={{
position: 'absolute', top: 10, right: 10,
minWidth: 18, height: 18, borderRadius: 9,
padding: '0 6px',
background: 'var(--err)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>{badge}</span>
)}
</button>
);
}
/* ============================================================
PrimaryButton — gros bouton plein largeur tactile
Nom système : PrimaryButton
Usage : action principale d'un écran (sauvegarder, valider).
============================================================ */
function PrimaryButton({ children, icon, onClick, variant = 'primary', size = 'lg' }) {
const sizes = {
md: { h: 44, fontSize: 14 },
lg: { h: 52, fontSize: 16 },
}[size];
const styles = {
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
danger: { bg: 'var(--err)', fg: '#fff', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="touch-press" style={{
width: '100%',
height: sizes.h,
background: styles.bg,
color: styles.fg,
border: `1px solid ${styles.bd}`,
borderRadius: 12,
fontFamily: 'var(--font-ui)', fontSize: sizes.fontSize, fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: variant === 'primary' ? '0 4px 12px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.2)' : 'var(--shadow-1)',
WebkitTapHighlightColor: 'transparent',
}}>
{icon && <Icon name={icon} size={18} />}
{children}
</button>
);
}
/* ============================================================
SegmentedControl — sélecteur segmenté iOS-style
Nom système : SegmentedControl
Usage : 2-4 options exclusives, jamais plus.
============================================================ */
function SegmentedControl({ value, onChange, options }) {
return (
<div style={{
display: 'flex',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 9,
padding: 3,
gap: 2,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'string' ? null : o.icon;
const active = value === v;
return (
<button key={v} onClick={() => onChange(v)} style={{
flex: 1, minHeight: 36,
padding: '6px 10px',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: 'none', borderRadius: 6,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
transition: 'background .18s, color .18s, transform .12s',
transform: active ? 'translateY(-0.5px)' : 'translateY(0)',
boxShadow: active ? '0 2px 5px var(--accent-glow)' : 'none',
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={13} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
SearchBar — champ de recherche mobile
Nom système : SearchBar
============================================================ */
function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name="search" size={15} style={{ color: 'var(--ink-3)' }} />
<input type="text" value={value} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15,
}} />
{value && (
<button onClick={() => onChange('')} style={{
width: 22, height: 22, borderRadius: '50%',
border: 'none', background: 'var(--ink-4)', color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}><Icon name="close" size={10} /></button>
)}
</div>
);
}
Object.assign(window, {
StatusBar, NavBar, TabBar, ListRow, ListSection,
ActionCard, PrimaryButton, SegmentedControl, SearchBar,
});
/* Effets tactiles : pression au touch (pas de hover) */
(function injectMobileFX() {
if (document.getElementById('mobile-fx')) return;
const s = document.createElement('style');
s.id = 'mobile-fx';
s.textContent = `
.touch-press {
transition: transform .08s ease-out, filter .08s, box-shadow .08s;
}
.touch-press:active {
transform: scale(0.97);
filter: brightness(0.92);
}
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,390 @@
/* ============================================================
mobile-sheets.jsx
Types de fenêtres mobiles + composants spécifiques.
Chaque type a un nom système ET un cas d'usage préconisé.
============================================================ */
const { useState: uS, useRef: rS, useEffect: eS } = React;
/* ============================================================
BottomSheet — feuille modale qui monte du bas
Nom système : BottomSheet
Cas d'usage : action contextuelle, formulaire court, choix
dans une liste. À privilégier sur mobile à la
place d'une popup centrée (plus accessible au pouce).
Gestes : swipe down pour fermer.
============================================================ */
function BottomSheet({ open, onClose, title, children, footer, height = 'auto' }) {
const [dragY, setDragY] = uS(0);
const [closing, setClosing] = uS(false);
const startY = rS(0);
eS(() => {
if (open) { setDragY(0); setClosing(false); }
}, [open]);
if (!open && !closing) return null;
const onStart = (e) => {
startY.current = (e.touches ? e.touches[0].clientY : e.clientY);
};
const onMove = (e) => {
const y = (e.touches ? e.touches[0].clientY : e.clientY);
const d = Math.max(0, y - startY.current);
setDragY(d);
};
const onEnd = () => {
if (dragY > 80) {
setClosing(true);
setTimeout(() => { setClosing(false); setDragY(0); onClose(); }, 200);
} else {
setDragY(0);
}
};
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: `rgba(0,0,0,${closing ? 0 : 0.5 * (1 - dragY / 400)})`,
transition: 'background .2s',
display: 'flex', alignItems: 'flex-end',
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
maxHeight: '85%',
height: height === 'auto' ? 'auto' : height,
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-2)',
borderRadius: '20px 20px 0 0',
boxShadow: '0 -8px 32px rgba(0,0,0,0.5)',
transform: `translateY(${closing ? '100%' : dragY + 'px'})`,
transition: closing ? 'transform .2s ease-in' : (dragY === 0 ? 'transform .3s cubic-bezier(.3,.7,.3,1.2)' : 'none'),
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle */}
<div onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart}
style={{
padding: '10px 0 6px',
display: 'flex', justifyContent: 'center',
cursor: 'grab', touchAction: 'none',
}}>
<div style={{
width: 36, height: 5, borderRadius: 3,
background: 'var(--ink-4)',
}}/>
</div>
{title && (
<div style={{
padding: '0 18px 12px',
display: 'flex', alignItems: 'center', gap: 8,
borderBottom: '1px solid var(--border-1)',
}}>
<div style={{ flex: 1, fontSize: 17, fontWeight: 700 }}>{title}</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: '50%',
background: 'var(--bg-4)', border: 'none', color: 'var(--ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
WebkitTapHighlightColor: 'transparent',
}}><Icon name="close" size={12} /></button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px 22px',
borderTop: '1px solid var(--border-1)',
display: 'flex', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
ActionSheet — menu d'actions style iOS
Nom système : ActionSheet
Cas d'usage : choix parmi 2-6 actions sur un élément
(équivalent menu contextuel desktop).
============================================================ */
function ActionSheet({ open, onClose, title, actions, cancelLabel = 'Annuler' }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end',
padding: 10,
animation: 'as-fade .2s',
}}>
<style>{`
@keyframes as-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes as-slide { from { transform: translateY(100%) } to { transform: translateY(0) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
display: 'flex', flexDirection: 'column', gap: 8,
animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}>
{title && (
<div style={{
padding: '12px 16px',
fontSize: 12, color: 'var(--ink-3)',
textAlign: 'center',
borderBottom: '1px solid var(--border-1)',
}}>{title}</div>
)}
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
width: '100%', minHeight: 52,
background: 'transparent', border: 'none',
borderTop: i === 0 || (title && i === 0) ? 'none' : '1px solid var(--border-1)',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={16} />}
{a.label}
</button>
))}
</div>
<button onClick={onClose} className="touch-press" style={{
width: '100%', minHeight: 52,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'var(--shadow-2)',
}}>{cancelLabel}</button>
</div>
</div>
);
}
/* ============================================================
AlertDialog — alerte modale centrée
Nom système : AlertDialog
Cas d'usage : message critique, demande de confirmation
ferme (suppression, déconnexion).
============================================================ */
function AlertDialog({ open, onClose, icon, iconColor, title, message, actions }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
animation: 'as-fade .2s',
}}>
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
animation: 'pop-in .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<style>{`@keyframes pop-in { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: scale(1) } }`}</style>
<div style={{
padding: '22px 22px 18px',
textAlign: 'center',
}}>
{icon && (
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: `color-mix(in oklch, ${iconColor || 'var(--accent)'} 18%, transparent)`,
color: iconColor || 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<Icon name={icon} size={24} />
</div>
)}
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', marginBottom: 6 }}>{title}</div>
{message && <div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.4 }}>{message}</div>}
</div>
<div style={{
display: 'flex',
borderTop: '1px solid var(--border-1)',
}}>
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
flex: 1, minHeight: 46,
background: 'transparent', border: 'none',
borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 15,
fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>{a.label}</button>
))}
</div>
</div>
</div>
);
}
/* ============================================================
Toast — notification éphémère en haut
Nom système : Toast
Cas d'usage : feedback succès/erreur après une action.
Disparaît automatiquement après 2.5s.
============================================================ */
function Toast({ open, onClose, icon, message, variant = 'ok', duration = 2500 }) {
eS(() => {
if (open) {
const t = setTimeout(onClose, duration);
return () => clearTimeout(t);
}
}, [open, duration, onClose]);
if (!open) return null;
const colors = {
ok: { bg: 'var(--ok)', fg: 'var(--bg-1)', icon: 'play' },
warn: { bg: 'var(--warn)', fg: 'var(--bg-1)', icon: 'alert' },
err: { bg: 'var(--err)', fg: '#fff', icon: 'close' },
info: { bg: 'var(--info)', fg: 'var(--bg-1)', icon: 'bell' },
}[variant];
return (
<div style={{
position: 'absolute', top: 50, left: 16, right: 16, zIndex: 300,
padding: '12px 16px',
background: colors.bg,
color: colors.fg,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'toast-in .3s cubic-bezier(.3,.7,.3,1.2)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>
<style>{`@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) } to { opacity: 1; transform: translateY(0) } }`}</style>
<Icon name={icon || colors.icon} size={18} />
<span style={{ flex: 1 }}>{message}</span>
</div>
);
}
/* ============================================================
FAB — Floating Action Button (Android Material)
Nom système : FAB
Cas d'usage : action principale unique sur un écran
(créer, ajouter). Toujours en bas à droite.
============================================================ */
function FAB({ icon, label, onClick }) {
return (
<button onClick={onClick} className="touch-press" style={{
position: 'absolute', bottom: 90, right: 18,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)',
color: 'var(--bg-1)',
border: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 18px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.4)',
zIndex: 50,
WebkitTapHighlightColor: 'transparent',
}} aria-label={label}>
<Icon name={icon} size={22} />
</button>
);
}
/* ============================================================
PullToRefresh — wrapper pour rafraîchir au pull-down
Nom système : PullToRefresh
Geste associé : swipe down depuis le haut du contenu.
============================================================ */
function PullToRefresh({ onRefresh, children }) {
const [pull, setPull] = uS(0);
const [refreshing, setRefreshing] = uS(false);
const startY = rS(0);
const wrap = rS();
const onStart = (e) => {
if (wrap.current && wrap.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
} else {
startY.current = null;
}
};
const onMove = (e) => {
if (startY.current == null) return;
const d = e.touches[0].clientY - startY.current;
if (d > 0) setPull(Math.min(d, 100));
};
const onEnd = async () => {
if (pull > 60 && !refreshing) {
setRefreshing(true);
setPull(60);
try { await Promise.resolve(onRefresh && onRefresh()); }
finally {
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setPull(0);
}
} else {
setPull(0);
}
startY.current = null;
};
return (
<div ref={wrap}
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
style={{ position: 'relative', overflowY: 'auto', height: '100%', WebkitOverflowScrolling: 'touch' }}>
{/* indicateur */}
<div style={{
position: 'absolute', top: -20 + pull, left: 0, right: 0,
display: 'flex', justifyContent: 'center',
transition: pull === 0 || pull === 60 ? 'top .2s ease-out' : 'none',
pointerEvents: 'none',
zIndex: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)',
boxShadow: 'var(--shadow-2)',
}}>
<Icon name="refresh" size={14} style={{
transform: `rotate(${pull * 4}deg)`,
animation: refreshing ? 'spin 1s linear infinite' : 'none',
transition: refreshing ? 'none' : 'transform .1s linear',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
<div style={{
transform: `translateY(${pull}px)`,
transition: pull === 0 || pull === 60 ? 'transform .2s ease-out' : 'none',
}}>{children}</div>
</div>
);
}
Object.assign(window, {
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
});
@@ -0,0 +1,137 @@
/* ============================================================
mobile-swipeable.jsx
SwipeableRow — ligne qui révèle des actions au swipe.
============================================================ */
const { useState: uSw, useRef: rSw, useEffect: eSw } = React;
/* ============================================================
SwipeableRow
Nom système : SwipeableRow
Cas d'usage : ligne d'une liste avec actions cachées
(archive, suppression, marquer comme lu…).
Style iOS Mail / Things / Apple Reminders.
Gestes : SwipeLeft (révèle leftActions à droite),
SwipeRight (révèle rightActions à gauche),
Tap sur la ligne (action principale),
Tap sur une action (déclenche l'action puis ferme).
============================================================ */
function SwipeableRow({ children, leftActions = [], rightActions = [], onTap }) {
// leftActions s'affichent quand on swipe vers la GAUCHE
// (la ligne se décale à gauche, dévoilant les actions à DROITE)
const [tx, setTx] = uSw(0);
const [dragging, setDragging] = uSw(false);
const startX = rSw(0);
const initialTx = rSw(0);
const leftW = leftActions.length * 76; // actions à droite (révélées par swipe gauche)
const rightW = rightActions.length * 76; // actions à gauche (révélées par swipe droit)
const snap = (x) => {
if (x < -leftW * 0.5) setTx(-leftW);
else if (x > rightW * 0.5) setTx(rightW);
else setTx(0);
};
const onStart = (e) => {
setDragging(true);
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
initialTx.current = tx;
};
const onMove = (e) => {
if (!dragging) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
let d = initialTx.current + (x - startX.current);
// limite + élasticité hors zone
if (d > rightW) d = rightW + (d - rightW) * 0.3;
if (d < -leftW) d = -leftW + (d + leftW) * 0.3;
setTx(d);
};
const onEnd = () => {
setDragging(false);
snap(tx);
};
const fire = (action) => {
setTx(0);
setTimeout(() => action.onClick && action.onClick(), 200);
};
const handleTap = (e) => {
if (tx !== 0) { setTx(0); return; }
if (Math.abs(tx) < 4 && onTap) onTap(e);
};
return (
<div style={{
position: 'relative',
overflow: 'hidden',
background: 'var(--bg-3)',
WebkitUserSelect: 'none', userSelect: 'none',
}}>
{/* Actions à GAUCHE (révélées par swipe droit) */}
{rightActions.length > 0 && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: rightW,
}}>
{rightActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--info)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Actions à DROITE (révélées par swipe gauche) */}
{leftActions.length > 0 && (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: leftW,
}}>
{leftActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--err)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Ligne déplaçable */}
<div
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart} onMouseMove={onMove} onMouseUp={onEnd} onMouseLeave={onEnd}
onClick={handleTap}
style={{
position: 'relative',
background: 'var(--bg-3)',
transform: `translateX(${tx}px)`,
transition: dragging ? 'none' : 'transform .25s cubic-bezier(.3,.7,.3,1.1)',
cursor: dragging ? 'grabbing' : (onTap ? 'pointer' : 'default'),
touchAction: 'pan-y',
}}>
{children}
</div>
</div>
);
}
Object.assign(window, { SwipeableRow });
@@ -0,0 +1,656 @@
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes — Font Awesome 6 Free.
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
dans le <head>. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
fontSize: size,
width: size,
height: size,
lineHeight: `${size}px`,
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
color: 'currentColor',
...style,
}} />
);
};
/* ============================================================
Tooltip — apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={onEnter} onMouseLeave={onLeave}>
{children}
{show && (
<span className="glass-strong" style={{
position: 'absolute', ...sides[side],
padding: '6px 10px',
borderRadius: 6,
fontSize: 12, lineHeight: 1.3,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
zIndex: 1000,
pointerEvents: 'none',
fontFamily: 'JetBrains Mono, monospace',
letterSpacing: '0.02em',
}}>{label}</span>
)}
</span>
);
}
/* ============================================================
IconButton — bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
<Tooltip label={label}>
<button onClick={onClick} className="interactive" style={{
width: size, height: size,
background: bg,
color: fg,
border: `1px solid ${bd}`,
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0, cursor: 'pointer',
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
}}>
<Icon name={icon} size={Math.round(size * 0.5)} />
</button>
</Tooltip>
);
}
/* ============================================================
Toggle on/off — switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
<button onClick={() => onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
<span style={{
position: 'absolute', top: 1, left: on ? 21 : 1,
width: 18, height: 18, borderRadius: '50%',
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
boxShadow: 'var(--shadow-1)',
}} />
</button>
</div>
);
}
/* ============================================================
Status LED — pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
)}
<span style={{
display: 'inline-block',
width: size, height: size,
borderRadius: '50%',
background: c,
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
flex: '0 0 auto',
}} />
</>
);
}
/* ============================================================
BatteryGauge — jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
<div className="bg-hover" style={{
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
'--bg-glow': glowVar,
}}>
{(icon || label) && (
<span style={{
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
minWidth: 90,
}}>
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
</span>
)}
<div className="bg-bar" style={{
flex: 1, height: 12, borderRadius: 3,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden', position: 'relative',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 2,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
</div>
<span className="mono" style={{
flex: '0 0 auto', fontSize: 13,
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
}}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
);
}
return (
<div className="bg-hover" style={{
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
'--bg-glow': glowVar,
}}>
{label && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="label">{label}</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
)}
<div className="bg-bar" style={{
position: 'relative',
height, borderRadius: 4,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 3,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
<div style={{
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
borderRadius: '3px 3px 0 0',
pointerEvents: 'none',
}} />
</div>
</div>
);
}
/* ============================================================
RadialGauge — jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
<defs>
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
</defs>
{/* arc background */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter={`url(#glow-${label})`} opacity="0.7" />
{/* arc value crisp */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
</svg>
<div style={{ marginTop: -10, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
</div>
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
</div>
</div>
);
}
/* ============================================================
BigRadialGauge — la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
<svg width={size} height={size * 0.85}>
<defs>
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
<stop offset="1" stopColor={color}/>
</linearGradient>
</defs>
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return <line key={i}
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
/>;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return <text key={v} x={x} y={y} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="11"
fill="var(--ink-3)">{v}</text>;
})}
{/* arc bg */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter="url(#biggauge-glow)" opacity="0.55" />
{/* arc value */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
{/* needle */}
<line x1={cx} y1={cy}
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
</svg>
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
<div className="mono" style={{
fontSize: 64, fontWeight: 700, lineHeight: 1,
color: 'var(--ink-1)',
textShadow: `0 0 20px ${color}33`,
}}>{value}</div>
<div className="label" style={{ marginTop: 6 }}>{label}</div>
</div>
</div>
);
}
/* ============================================================
Popup — modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadein .2s ease-out',
}} onClick={onClose}>
<style>{`
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-1)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-3)',
}}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
</div>
<div style={{ padding: 18 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-1)',
background: 'var(--bg-2)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
Button — bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="interactive" style={{
height: sizes.h,
padding: sizes.padding,
background: variants.bg,
color: variants.fg,
border: `1px solid ${variants.bd}`,
borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
cursor: 'pointer',
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
}
/* ============================================================
TreeNav — arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<div key={g.id}>
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
<Icon name="chevR" size={12} style={{
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform .15s',
color: 'var(--ink-3)',
}} />
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
{g.count != null && (
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{g.count}
</span>
)}
</div>
{open[g.id] && (
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
{g.children.map(c => {
const active = c.id === activeId;
return (
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
<path d={area} fill={color} opacity="0.12" />
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ============================================================
LineChart — grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
<g key={p}>
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
<text x={padding.l - 6} y={y + 3} textAnchor="end"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
</g>
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
<g key={si}>
<path d={area} fill={s.color} opacity="0.12" />
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
</svg>
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,192 @@
# Consignes mobile — mon design system
> **Tu es un agent IA qui produit du code mobile avec ce design system.**
> Lis ce fichier ENTIER avant d'écrire la moindre ligne. Suis les règles à la lettre.
---
## 🎯 Identité
- **Cibles** : iOS et Android via HTML/JS (Cordova, Capacitor, ou PWA)
- **Largeur ref** : 390px (iPhone 14 / Galaxy S22)
- **Hit target min** : 44 × 44px (Apple HIG / Material)
- **Style** : Gruvbox seventies — orange brûlé, fond brun délavé en sombre / gris clair usé en clair
---
## 📁 Fichiers
| Fichier | Composants exposés sur window |
|-------------------------------|----------------------------------------------------------------------------|
| `components/ui-kit.jsx` | Icon, Tooltip, Button, IconButton, Toggle, StatusLed, Popup, BatteryGauge, RadialGauge, BigRadialGauge, TreeNav, Sparkline, LineChart |
| `components/mobile-kit.jsx` | StatusBar, NavBar, TabBar, ListRow, ListSection, ActionCard, PrimaryButton, SegmentedControl, SearchBar |
| `components/mobile-sheets.jsx`| BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh |
| `components/mobile-gestures.jsx`| useGesture, GestureZone, GESTURE_CATALOG |
| `components/mobile-swipeable.jsx`| SwipeableRow |
| `components/mobile-forms.jsx` | FormField, TextInput, DateInput, Dropdown, CheckboxItem, RadioGroup, MediaInsert, AvatarLogo, BiometricButton |
| `components/mobile-apps.jsx` | Avatar, AvatarMenu, OnboardingSlider, ChatBubble, ChatComposer, CalendarMonth, MapView, FilterChips, QrScannerView, CameraView, FileExplorer |
---
## ⚠️ Règles absolues
1. **Hit targets ≥ 44 × 44 px** sur TOUT élément tactile. Pas de petits boutons.
2. **Pas de hover** — c'est du tactile. Utilise la pression au touch via `.touch-press`.
3. **Tooltips** sur tous les `<IconButton>` isolés (la prop `label` les active automatiquement).
4. **Toujours des polices natives mobile** : Inter / JetBrains Mono / Share Tech Mono via tokens.
5. **Animations fluides** : 180-300ms, easing `cubic-bezier(.3,.7,.3,1.2)` pour entrée, `cubic-bezier(.3,.6,.3,1)` pour mouvement.
6. **Toujours `<TabBar>` en bas** comme navigation primaire (3-5 sections).
7. **JAMAIS de popup centrée modale standard** — utilise `BottomSheet`, `ActionSheet`, `AlertDialog` ou `Toast`.
8. **Bouton Avatar en haut à droite** de chaque écran principal pour accès rapide au menu utilisateur.
9. **Variables CSS uniquement** — pas de hex en dur (`color: var(--accent)`, jamais `color: #fe8019`).
10. **Smartphone d'abord** — toute interaction doit fonctionner avec un seul pouce.
---
## 🧩 Cas → Composant à utiliser
### Structure d'écran (toujours)
```jsx
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Mon écran" right={<Avatar name="M" onClick={openMenu} />} />
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
{/* contenu */}
</div>
</div>
```
Si la page fait partie d'une nav principale, ajoute `<TabBar>` après. Si elle a une action principale flottante, ajoute `<FAB>`.
### Liste à actions cachées
```jsx
<SwipeableRow
onTap={() => open(item)}
leftActions={[{ label: 'Suppr.', icon: 'close', color: 'var(--err)', onClick: del }]} // swipe gauche
rightActions={[{ label: 'Lu', icon: 'play', color: 'var(--info)', onClick: read }]} // swipe droit
>
<div>{contenu de la ligne}</div>
</SwipeableRow>
```
### Champ texte typé
```jsx
// Email
<TextInput value={...} onChange={...} keyboard="email" autocomplete="email" autocapitalize="off" />
// Mot de passe
<TextInput value={...} onChange={...} type="password" autocomplete="current-password" />
// OTP SMS
<TextInput value={...} onChange={...} keyboard="numeric" autocomplete="one-time-code" maxLength={6} />
// Recherche
<TextInput value={...} onChange={...} keyboard="search" enterHint="search" />
```
### Confirmation destructive
```jsx
<AlertDialog open={open} onClose={...}
icon="alert" iconColor="var(--err)"
title="Supprimer ?"
message="Cette action est irréversible."
actions={[
{ label: 'Annuler' },
{ label: 'Supprimer', danger: true, primary: true, onClick: del },
]} />
```
### Choix dans une liste
```jsx
<BottomSheet open={...} onClose={...} title="Choisir">
<RadioGroup value={...} onChange={...} options={[...]} />
</BottomSheet>
```
### Menu d'actions sur élément
```jsx
<ActionSheet open={...} onClose={...} title="Actions"
actions={[
{ label: 'Modifier', icon: 'cog' },
{ label: 'Partager', icon: 'download' },
{ label: 'Supprimer', icon: 'close', danger: true },
]} />
```
### Feedback après action
```jsx
<Toast open={msg !== null} onClose={() => setMsg(null)} message={msg} variant="ok" />
```
### Menu utilisateur (avatar haut-droite)
```jsx
<AvatarMenu open={...} onClose={...} name="Marc" email="marc@..."
items={[
{ icon: 'user', label: 'Mon profil' },
{ icon: 'cog', label: 'Paramètres' },
{ icon: 'power', label: 'Se déconnecter', danger: true },
]} />
```
---
## 🚫 Anti-patterns
-`window.alert / confirm` → utilise `AlertDialog`
-`<button>` nu sans hit target → utilise `IconButton` ou `PrimaryButton`
- ❌ Hover effects → pas d'hover sur mobile, utilise `.touch-press`
- ❌ Popups centrées pour formulaire court → `BottomSheet`
- ❌ Sidebars > 240px → utilise plutôt drawer ou TabBar
- ❌ Tableaux denses → liste swipeable avec actions
- ❌ Couleurs en dur → toujours `var(--token)`
- ❌ Police arbitraire → `var(--font-ui)`, `var(--font-mono)`, `var(--font-terminal)`
- ❌ Boutons texte sans icône pour actions critiques → ajoute toujours une icône
- ❌ Inputs sans `keyboard`/`autocomplete` adaptés → frustre l'utilisateur
---
## 📐 Tailles
| Élément | Taille |
|----------------------|--------------------------------------------------|
| StatusBar | 44px |
| NavBar compact | 52px |
| NavBar large | ~90px |
| TabBar | 70px (avec safe area) |
| ListRow | min 52px |
| PrimaryButton lg | 52px |
| IconButton | 34px (def) / 26px (compact) / 44px (large) |
| FAB | 56 × 56 ronde |
| Toggle | 42 × 22 |
| Radius cartes | 10-14px |
| Radius boutons | 8-12px |
| Avatar | 36px (header) / 48-72px (profile) |
| Espacement standard | 8 / 12 / 14 / 18 / 24px |
---
## 💡 Détails à respecter
- **Safe area bottom** : la TabBar a déjà un `padding-bottom: 18px` pour la home indicator iOS.
- **Backdrop-filter blur** : utilisé sur NavBar/TabBar/AlertBg pour effet vitre.
- **SwipeableRow** snap : ouvre/ferme à 50% de la largeur des actions.
- **AvatarMenu** : un seul ouvert à la fois, ferme au clic extérieur (backdrop).
- **Toast** : auto-ferme à 2.5s sauf prop `duration` différent.
- **PullToRefresh** : seulement quand `scrollTop === 0`.
---
## 🌗 Dark / Light
Tout fonctionne automatiquement via `data-theme="dark|light"` sur un parent. **Toujours tester les deux** :
- En sombre : tokens chauds bruns
- En clair : gris clair usé (pas blanc pur), accent orange plus contrasté
---
## 🔚 En cas de doute
- Composant pas sûr ? → `examples/exemple-mobile-apps.html` montre quasi tous les cas
- Geste pas clair ? → onglet Gestes du smartphone dans `exemple-mobile.html`
- Saisie spéciale ? → `exemple-mobile-saisie.html` + section "Antisèche"
Toujours préférer un composant existant à un custom. Quand tu doutes, **demande**.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — patterns d'app</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background: radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow: 0 0 0 2px #2a2520, 0 0 0 8px #1a1612, 0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907; border-radius: 18px;
z-index: 100; pointer-events: none;
}
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px; padding: 3px; gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px; background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3); cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em; text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent); color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
.doc { min-width: 0; }
.doc section { margin-bottom: 36px; scroll-margin-top: 80px; }
.doc h2 {
font-size: 22px; margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px; background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px; box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px; color: var(--accent); font-weight: 600;
}
.doc .row-use {
display: grid; grid-template-columns: 130px 1fr; gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel" src="mobile-swipeable.jsx"></script>
<script type="text/babel" src="mobile-forms.jsx"></script>
<script type="text/babel" src="mobile-apps.jsx"></script>
<script type="text/babel" src="exemple-mobile-apps-combined.jsx"></script>
</body>
</html>
@@ -0,0 +1,341 @@
/* ============================================================
exemple-mobile-saisie-app.jsx — partie 1
Écrans du smartphone (Login, Profile, Form, SwipeList).
La partie Doc + ROOT est dans exemple-mobile-saisie-doc.jsx.
============================================================ */
const { useState: uMS, useEffect: eMS } = React;
/* ============================================================
ÉCRAN 1 — Login
============================================================ */
function ScreenLogin({ onLogin, showToast }) {
const [email, setEmail] = uMS('');
const [pwd, setPwd] = uMS('');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<div style={{
flex: 1, padding: '32px 24px', overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: 16,
}}>
<div style={{ textAlign: 'center', marginTop: 24, marginBottom: 12 }}>
<AvatarLogo icon="server" size={80} />
<div style={{ fontSize: 26, fontWeight: 700, marginTop: 16 }}>Bienvenue</div>
<div style={{ fontSize: 14, color: 'var(--ink-3)', marginTop: 4 }}>Connecte-toi à ton compte</div>
</div>
<FormField label="Email">
<TextInput value={email} onChange={setEmail}
placeholder="prenom@exemple.com"
type="email" icon="bell"
keyboard="email"
autocomplete="email"
autocapitalize="off"
spellCheck={false}
enterHint="next" />
</FormField>
<FormField label="Mot de passe" hint="≥ 8 caractères, 1 chiffre">
<TextInput value={pwd} onChange={setPwd}
placeholder="••••••••"
type="password" icon="power"
keyboard="text"
autocomplete="current-password"
autocapitalize="off"
spellCheck={false}
enterHint="go" />
</FormField>
<a href="#" onClick={(e) => { e.preventDefault(); showToast('Email de réinitialisation envoyé'); }}
style={{ fontSize: 13, color: 'var(--accent)', textAlign: 'right', textDecoration: 'none' }}>
Mot de passe oublié ?
</a>
<div style={{ marginTop: 6 }}>
<PrimaryButton icon="play" onClick={() => { onLogin(); showToast('Bienvenue Marc !'); }}>
Se connecter
</PrimaryButton>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0',
color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11,
}}>
<div style={{ flex: 1, height: 1, background: 'var(--border-2)' }}></div>
OU
<div style={{ flex: 1, height: 1, background: 'var(--border-2)' }}></div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 24 }}>
<BiometricButton kind="face" onClick={() => { onLogin(); showToast('Face ID OK'); }} />
<BiometricButton kind="touch" onClick={() => { onLogin(); showToast('Touch ID OK'); }} />
</div>
<div style={{ textAlign: 'center', marginTop: 16, fontSize: 14, color: 'var(--ink-3)' }}>
Pas encore de compte ?{' '}
<a href="#" onClick={(e) => e.preventDefault()} style={{ color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>
S'inscrire
</a>
</div>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 2 — Profile (avec bouton Paramètres haut-droite)
============================================================ */
function ScreenProfile({ openSettings }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Profil" right={
<IconButton icon="cog" label="Paramètres" onClick={openSettings} size={34} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0 80px' }}>
<div style={{
padding: '20px 16px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<AvatarLogo icon="user" size={72} />
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 8 }}>Marc Dupont</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)' }} className="mono">admin · marc@exemple.com</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<span style={{
padding: '3px 10px', borderRadius: 999,
background: 'var(--ok-glow)', color: 'var(--ok)',
border: '1px solid var(--ok)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>● connecté</span>
<span style={{
padding: '3px 10px', borderRadius: 999,
background: 'var(--accent-tint)', color: 'var(--accent)',
border: '1px solid var(--accent)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.06em',
}}>premium</span>
</div>
</div>
<ListSection title="Mon compte">
<ListRow icon="user" iconColor="var(--blue)" label="Informations personnelles" onClick={() => {}} />
<ListRow icon="bell" iconColor="var(--accent)" label="Notifications" value="3" onClick={() => {}} />
<ListRow icon="power" iconColor="var(--ok)" label="Sécurité & connexion" onClick={() => {}} />
</ListSection>
<ListSection title="Mes données">
<ListRow icon="download" iconColor="var(--info)" label="Exporter mes données" onClick={() => {}} />
<ListRow icon="folder" iconColor="var(--purple)" label="Mes documents" value="124" onClick={() => {}} />
</ListSection>
<ListSection>
<ListRow icon="close" iconColor="var(--ink-4)" label="Se déconnecter" onClick={() => {}} />
</ListSection>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 3 — Form (formulaire de saisie complet)
============================================================ */
function ScreenForm({ showToast, openSheet }) {
const [title, setTitle] = uMS('');
const [date, setDate] = uMS('2026-05-21');
const [time, setTime] = uMS('14:30');
const [body, setBody] = uMS('');
const [category, setCategory] = uMS('');
const [priority, setPriority] = uMS('normal');
const [tags, setTags] = uMS({ urgent: false, perso: true, travail: false });
const [confirmed, setConfirmed] = uMS(false);
const [media, setMedia] = uMS([]);
const onMedia = (kind, data) => {
if (kind === 'gps' && data && data.lat) {
setMedia([...media, { kind: 'gps', label: `GPS · ${data.lat.toFixed(4)}, ${data.lon.toFixed(4)}` }]);
} else if (data && data.name) {
setMedia([...media, { kind, label: `${kind} · ${data.name}` }]);
} else {
showToast(`${kind} sélectionné`);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Nouvelle note"
onBack={() => showToast('Retour')}
right={
<button onClick={() => { showToast('Enregistré'); }} style={{
padding: '6px 12px',
background: 'transparent', border: 'none',
color: 'var(--accent)', fontFamily: 'var(--font-ui)',
fontWeight: 700, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>OK</button>
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px 80px' }}>
<FormField label="Titre" required>
<TextInput value={title} onChange={setTitle}
placeholder="Titre de la note"
keyboard="text" autocapitalize="sentences"
enterHint="next" maxLength={80} icon="list" />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<FormField label="Date">
<DateInput value={date} onChange={setDate} mode="date" />
</FormField>
<FormField label="Heure">
<DateInput value={time} onChange={setTime} mode="time" />
</FormField>
</div>
<FormField label="Contenu" hint="Décris ce qui doit être fait.">
<TextInput value={body} onChange={setBody}
placeholder="Tape ton texte ici…"
multiline rows={4}
keyboard="text" autocapitalize="sentences"
spellCheck={true} />
</FormField>
<FormField label="Catégorie">
<Dropdown value={category} onChange={setCategory}
placeholder="Choisir une catégorie…"
options={[
{ value: 'todo', label: 'À faire' },
{ value: 'note', label: 'Note simple' },
{ value: 'meeting', label: 'Réunion' },
{ value: 'bug', label: 'Bug à corriger' },
]} />
</FormField>
<FormField label="Priorité">
<RadioGroup value={priority} onChange={setPriority} options={[
{ value: 'low', label: 'Basse', description: 'Sans urgence' },
{ value: 'normal', label: 'Normale', description: 'Par défaut' },
{ value: 'high', label: 'Haute', description: 'À traiter rapidement' },
]} />
</FormField>
<FormField label="Étiquettes">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.entries({ urgent: 'Urgent', perso: 'Perso', travail: 'Travail' }).map(([k, v]) => (
<CheckboxItem key={k}
checked={tags[k]}
onChange={(c) => setTags({ ...tags, [k]: c })}
label={v} />
))}
</div>
</FormField>
<FormField label="Pièces jointes">
<MediaInsert onPick={onMedia} />
{media.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
{media.map((m, i) => (
<div key={i} style={{
padding: '8px 12px', fontSize: 12, fontFamily: 'var(--font-mono)',
background: 'var(--bg-3)', border: '1px solid var(--border-2)',
borderRadius: 8, color: 'var(--ink-2)',
}}>📎 {m.label}</div>
))}
</div>
)}
</FormField>
<FormField label="Code de confirmation" hint="On t'envoie un code par SMS.">
<TextInput value="" onChange={() => {}}
placeholder="123456"
keyboard="numeric"
autocomplete="one-time-code"
maxLength={6} icon="bell" />
</FormField>
<CheckboxItem checked={confirmed} onChange={setConfirmed}
label="J'accepte les conditions"
description="En cochant, tu acceptes notre politique." />
<div style={{ marginTop: 16 }}>
<PrimaryButton icon="download" onClick={() => showToast('Note enregistrée')}>
Enregistrer la note
</PrimaryButton>
</div>
</div>
</div>
);
}
/* ============================================================
ÉCRAN 4 — Liste avec SwipeableRow
============================================================ */
function ScreenSwipe({ showToast }) {
const [items, setItems] = uMS([
{ id: 1, title: 'Sauvegarde serveur OK', from: 'cron@srv', time: '14:02', unread: true },
{ id: 2, title: 'Latence élevée détectée', from: 'monitoring', time: '13:58', unread: true },
{ id: 3, title: 'Rappel : réunion équipe', from: 'agenda', time: '11:30', unread: false },
{ id: 4, title: 'Mise à jour disponible', from: 'systeme', time: '09:14', unread: false },
{ id: 5, title: 'Nouveau hôte sur le réseau', from: 'ipwatch', time: '08:42', unread: true },
]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Notifications" subtitle="essaie de swiper une ligne ←→" />
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 80 }}>
{items.map((it) => (
<SwipeableRow key={it.id}
onTap={() => showToast(`Ouvrir : ${it.title}`)}
rightActions={[
{ label: 'Lu', icon: 'play', color: 'var(--info)',
onClick: () => setItems(items.map((x) => x.id === it.id ? { ...x, unread: false } : x)) },
{ label: 'Épingl.', icon: 'bell', color: 'var(--accent)',
onClick: () => showToast('Épinglé') },
]}
leftActions={[
{ label: 'Archiv.', icon: 'folder', color: 'var(--ok)',
onClick: () => { setItems(items.filter((x) => x.id !== it.id)); showToast('Archivé'); } },
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => { setItems(items.filter((x) => x.id !== it.id)); showToast('Supprimé'); } },
]}>
<div style={{
padding: '14px 16px',
display: 'flex', gap: 12, alignItems: 'flex-start',
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 10, height: 10, borderRadius: '50%',
background: it.unread ? 'var(--accent)' : 'transparent',
marginTop: 6, flex: '0 0 auto',
boxShadow: it.unread ? '0 0 6px var(--accent-glow)' : 'none',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: it.unread ? 700 : 500, color: 'var(--ink-1)' }}>{it.from}</span>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }} className="mono">{it.time}</span>
</div>
<div style={{ fontSize: 14, color: 'var(--ink-2)', marginTop: 2 }}>{it.title}</div>
</div>
</div>
</SwipeableRow>
))}
{items.length === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ink-3)' }}>
Plus de notifications — fais un swipe sur une ligne ←→ pour voir les actions.
</div>
)}
<div style={{
padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--ink-4)',
fontFamily: 'var(--font-mono)',
}}>
swipe gauche : archiver/supprimer · swipe droit : marquer lu/épingler
</div>
</div>
</div>
);
}
Object.assign(window, { ScreenLogin, ScreenProfile, ScreenForm, ScreenSwipe });
@@ -0,0 +1,486 @@
/* ============================================================
exemple-mobile-saisie-doc.jsx — partie 2
Doc panneau droit (catalogue commenté avec visuels) + ROOT.
============================================================ */
const { useState: uDS, useEffect: eDS } = React;
/* ============================================================
VISUALS ============================================================ */
/* Mini-clavier virtuel selon le type */
function KeyboardVisual({ kind }) {
const wrap = (cells) => (
<div style={{
padding: 10, background: 'var(--bg-1)',
border: '1px solid var(--border-2)', borderRadius: 8,
display: 'flex', flexDirection: 'column', gap: 4,
width: '100%',
}}>{cells.map((c, i) => <React.Fragment key={i}>{c}</React.Fragment>)}</div>
);
const row = (keys, big) => (
<div style={{ display: 'flex', gap: 3, justifyContent: 'center' }}>
{keys.map((k, i) => (
<span key={i} style={{
flex: big ? 1 : '0 1 auto',
minWidth: big ? 0 : 16, height: 22, padding: '0 4px',
background: k === '↵' || k === 'Rechercher' || k === 'Aller' || k === 'OK' ? 'var(--accent)' : 'var(--bg-3)',
color: k === '↵' || k === 'Rechercher' || k === 'Aller' || k === 'OK' ? 'var(--bg-1)' : 'var(--ink-1)',
border: '1px solid var(--border-2)',
borderRadius: 4,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
}}>{k}</span>
))}
</div>
);
if (kind === 'text') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','espace','↵'], true)]);
if (kind === 'numeric') return wrap([row(['1','2','3'], true), row(['4','5','6'], true), row(['7','8','9'], true), row(['','0','⌫'], true)]);
if (kind === 'decimal') return wrap([row(['1','2','3'], true), row(['4','5','6'], true), row(['7','8','9'], true), row([',','0','⌫'], true)]);
if (kind === 'tel') return wrap([row(['1','2 ABC','3 DEF'], true), row(['4 GHI','5 JKL','6 MNO'], true), row(['7 PQRS','8 TUV','9 WXYZ'], true), row(['+ * #','0 +','⌫'], true)]);
if (kind === 'email') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','@','espace','.','↵'], true)]);
if (kind === 'url') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','.','/','.com','Aller'], true)]);
if (kind === 'search') return wrap([row(['q','w','e','r','t','y','u','i','o','p']), row(['a','s','d','f','g','h','j','k','l']), row(['z','x','c','v','b','n','m','⌫']), row(['123','espace','Rechercher'], true)]);
if (kind === 'none') return (
<div style={{
padding: 14, background: 'var(--bg-1)',
border: '1px dashed var(--border-3)', borderRadius: 8,
textAlign: 'center', color: 'var(--ink-4)',
fontFamily: 'var(--font-mono)', fontSize: 11,
}}>(aucun clavier picker custom)</div>
);
return null;
}
/* Mini SVG phone pour montrer les écrans */
function ScreenVisual({ type }) {
const phone = (inner) => (
<svg viewBox="0 0 100 180" width="80" height="144" style={{ display: 'block' }}>
<rect x="3" y="2" width="94" height="176" rx="14" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
{inner}
</svg>
);
if (type === 'login') return phone(
<g>
<circle cx="50" cy="40" r="12" fill="var(--accent)"/>
<rect x="20" y="68" width="60" height="9" rx="4" fill="var(--bg-1)" stroke="var(--border-2)"/>
<rect x="20" y="82" width="60" height="9" rx="4" fill="var(--bg-1)" stroke="var(--border-2)"/>
<rect x="20" y="100" width="60" height="11" rx="5" fill="var(--accent)"/>
<line x1="22" y1="125" x2="42" y2="125" stroke="var(--ink-4)" strokeWidth="0.5"/>
<line x1="58" y1="125" x2="78" y2="125" stroke="var(--ink-4)" strokeWidth="0.5"/>
<text x="50" y="128" textAnchor="middle" fontSize="6" fontFamily="JetBrains Mono" fill="var(--ink-4)">OU</text>
<circle cx="42" cy="145" r="6" fill="none" stroke="var(--accent)" strokeWidth="1"/>
<circle cx="58" cy="145" r="6" fill="none" stroke="var(--accent)" strokeWidth="1"/>
</g>
);
if (type === 'profile') return phone(
<g>
<rect x="3" y="14" width="94" height="14" fill="var(--bg-2)"/>
<text x="50" y="24" textAnchor="middle" fontSize="6" fontFamily="Inter" fontWeight="700" fill="var(--ink-1)">Profil</text>
<rect x="80" y="17" width="9" height="9" rx="2" fill="var(--accent-tint)" stroke="var(--accent)" strokeWidth="0.5"/>
<circle cx="50" cy="48" r="12" fill="var(--accent)"/>
<rect x="30" y="65" width="40" height="5" rx="2" fill="var(--ink-2)"/>
<rect x="36" y="74" width="28" height="3" rx="1.5" fill="var(--ink-4)"/>
<rect x="10" y="92" width="80" height="14" rx="4" fill="var(--bg-2)"/>
<rect x="10" y="110" width="80" height="14" rx="4" fill="var(--bg-2)"/>
<rect x="10" y="128" width="80" height="14" rx="4" fill="var(--bg-2)"/>
</g>
);
if (type === 'form') return phone(
<g>
<rect x="10" y="22" width="80" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="36" width="38" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="52" y="36" width="38" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="50" width="80" height="22" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="78" width="80" height="9" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<circle cx="16" cy="98" r="2.5" fill="none" stroke="var(--accent)"/>
<rect x="22" y="96" width="40" height="2.5" rx="1" fill="var(--ink-3)"/>
<circle cx="16" cy="106" r="2.5" fill="var(--accent)"/>
<rect x="22" y="104" width="40" height="2.5" rx="1" fill="var(--ink-3)"/>
<rect x="10" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--accent)"/>
<rect x="38" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="66" y="120" width="24" height="14" rx="3" fill="var(--bg-2)" stroke="var(--border-2)"/>
<rect x="10" y="158" width="80" height="12" rx="5" fill="var(--accent)"/>
</g>
);
if (type === 'swipe') return phone(
<g>
<rect x="3" y="14" width="94" height="14" fill="var(--bg-2)"/>
<text x="50" y="24" textAnchor="middle" fontSize="6" fontFamily="Inter" fontWeight="700" fill="var(--ink-1)">Boîte</text>
<rect x="3" y="32" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="52" x2="97" y2="52" stroke="var(--border-1)" strokeWidth="0.4"/>
<rect x="3" y="52" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="72" x2="97" y2="72" stroke="var(--border-1)" strokeWidth="0.4"/>
<g transform="translate(-26, 0)">
<rect x="3" y="72" width="94" height="20" fill="var(--bg-3)"/>
</g>
<rect x="71" y="72" width="13" height="20" fill="var(--info)"/>
<rect x="84" y="72" width="13" height="20" fill="var(--accent)"/>
<text x="77.5" y="85" textAnchor="middle" fontSize="5" fontFamily="Inter" fontWeight="700" fill="var(--bg-1)">Lu</text>
<text x="90.5" y="85" textAnchor="middle" fontSize="5" fontFamily="Inter" fontWeight="700" fill="var(--bg-1)">Pin</text>
<rect x="3" y="92" width="94" height="20" fill="var(--bg-3)"/>
<line x1="3" y1="112" x2="97" y2="112" stroke="var(--border-1)" strokeWidth="0.4"/>
<rect x="3" y="112" width="94" height="20" fill="var(--bg-3)"/>
<path d="M 80 102 l -6 0 M 80 102 l 4 -3 M 80 102 l 4 3" stroke="var(--accent)" strokeWidth="1" fill="none"/>
</g>
);
return phone(null);
}
/* ============================================================
DOC PANEL
============================================================ */
function NamedItem({ name, desc, location, preview }) {
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span className="pill-name">&lt;{name}/&gt;</span>
{location && <span className="legend">📍 {location}</span>}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{desc}</div>
{preview && (
<div style={{
marginTop: 12, padding: 12,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
}}>{preview}</div>
)}
</div>
);
}
function ScreenCard({ type, name, when, why, gestures, example }) {
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 14, alignItems: 'flex-start' }}>
<ScreenVisual type={type} />
<div style={{ minWidth: 0 }}>
<span className="pill-name" style={{ marginBottom: 10, display: 'inline-flex' }}>Écran {name}</span>
<div className="row-use"><span className="k">Quand</span><span className="v">{when}</span></div>
<div className="row-use"><span className="k">Pourquoi</span><span className="v">{why}</span></div>
{gestures && <div className="row-use"><span className="k">Gestes</span><span className="v">{gestures}</span></div>}
<div className="row-use"><span className="k">Tester</span><span className="v" style={{ color:'var(--accent)' }}>{example}</span></div>
</div>
</div>
</div>
);
}
function Doc() {
return (
<div className="doc">
{/* INTRO */}
<section>
<h2><Icon name="list" size={22} style={{ color: 'var(--accent)' }} /> Saisie & formulaires mobile</h2>
<p className="desc">
Suite logique de la variante mobile : <strong>écrans de connexion, profil, formulaire complet,
liste swipeable</strong>. Tous les composants sont nommés et le clavier virtuel se configure
précisément (8 types, autocomplete système, touche Entrée personnalisable).
</p>
</section>
{/* ÉCRANS */}
<section id="screens">
<h2><Icon name="grid" size={22} style={{ color: 'var(--accent)' }} /> 4 écrans modèles</h2>
<p className="desc">Chaque écran combine plusieurs composants. Bascule entre eux via les onglets en bas du smartphone.</p>
<ScreenCard
type="login"
name="Connexion"
when="Avant tout accès à l'app, ou pour se reconnecter."
why="Format unifié : logo + email + mot de passe + biométrie + lien créa de compte."
gestures="Tap sur champs · Tap sur Face ID / Touch ID · enterKeyHint='go' soumet le formulaire"
example="Onglet ☐ Login du smartphone à gauche" />
<ScreenCard
type="profile"
name="Profil utilisateur"
when="L'utilisateur veut voir/modifier ses infos."
why="Tête de page avec avatar + actions de compte + bouton ⚙ paramètres en haut à droite."
gestures="Tap sur ⚙ ouvre une BottomSheet de paramètres"
example="Onglet ☐ Profil du smartphone" />
<ScreenCard
type="form"
name="Formulaire de saisie"
when="Création/édition d'un objet (note, tâche, contact…)."
why="Tous les types d'inputs en une seule page : titre, dates, textarea, dropdown, radio, checkboxes, médias."
gestures="Tap sur OK valide · onBack remonte d'un cran"
example="Onglet ☐ Formulaire du smartphone" />
<ScreenCard
type="swipe"
name="Liste swipeable"
when="Liste d'éléments avec actions cachées (mails, notifs, tâches)."
why="Économise l'espace : actions hors-écran révélées au geste."
gestures="SwipeLeft → archive/supprime · SwipeRight → marquer lu/épingler · Tap → ouvrir"
example="Onglet ☐ Notifications du smartphone" />
</section>
{/* COMPOSANTS */}
<section id="components">
<h2><Icon name="cog" size={22} style={{ color: 'var(--accent)' }} /> Composants de saisie</h2>
<p className="desc">Tous ont une API homogène : <code className="mono" style={{color:'var(--accent)'}}>value / onChange / label / hint / error</code>. Les inputs supportent en plus le contrôle clavier virtuel.</p>
<NamedItem name="FormField" location="Wrapper de tout champ"
desc="Cadre standard : label en haut, champ au milieu, hint/erreur en bas. À utiliser autour de chaque champ pour homogénéiser." />
<NamedItem name="TextInput" location="Formulaire, Login"
desc="Champ texte unifié avec contrôle complet du clavier virtuel : type d'entrée (text/email/numeric/tel…), auto-complétion système (email, mot de passe, code OTP), texte de la touche Entrée (next, send, search…), majuscules auto, correction orthographique. Mode multiline pour textarea."
preview={<TextInput value="exemple@..." onChange={() => {}} keyboard="email" icon="bell" />} />
<NamedItem name="DateInput" location="Formulaire"
desc="Date/heure picker natif du téléphone. Modes : date, time, datetime-local, month, week. Affiche le picker iOS/Android natif au focus."
preview={<DateInput value="2026-05-21" onChange={() => {}} mode="date" />} />
<NamedItem name="Dropdown" location="Formulaire"
desc="Select natif avec habillage Gruvbox. Sur mobile, ouvre le sélecteur roulette iOS ou le menu déroulant Android. À utiliser dès 4+ options."
preview={<Dropdown value="" onChange={() => {}} placeholder="Choisir…" options={['Option A', 'Option B', 'Option C']} />} />
<NamedItem name="CheckboxItem" location="Formulaire"
desc="Case à cocher avec label + description optionnelle. Pour des options indépendantes (multi-sélection)."
preview={<CheckboxItem checked={true} onChange={() => {}} label="J'accepte les conditions" description="En cochant tu acceptes notre politique." />} />
<NamedItem name="RadioGroup" location="Formulaire"
desc="Liste d'options exclusives empilées verticalement avec puce circulaire. Pour 2-6 options. Au-delà, utilise un Dropdown."
preview={<RadioGroup value="b" onChange={() => {}} options={[
{ value: 'a', label: 'Option A', description: 'Première option' },
{ value: 'b', label: 'Option B', description: 'Deuxième option' },
]} />} />
<NamedItem name="MediaInsert" location="Formulaire"
desc="Grille 3 colonnes de boutons pour ajouter une pièce jointe : Photo (caméra arrière), Image (galerie), Vidéo, Audio (micro), Fichier (doc), Position (GPS via navigator.geolocation). Chaque type définit l'attribut HTML accept et capture."
preview={<MediaInsert onPick={() => {}} />} />
<NamedItem name="AvatarLogo" location="Login, Profil"
desc="Gros logo carré arrondi avec icône et glow accent. Pour l'identité visuelle d'un écran (login, profil, vide d'état)."
preview={<AvatarLogo icon="server" size={48} />} />
<NamedItem name="BiometricButton" location="Login"
desc="Bouton biométrique (Face ID / Touch ID). Style natif iOS — icône large + label. À placer sous le bouton principal de login."
preview={<div style={{display:'flex', gap: 16, justifyContent:'center'}}><BiometricButton kind="face" /><BiometricButton kind="touch" /></div>} />
<NamedItem name="SwipeableRow" location="Liste swipeable"
desc="Ligne d'une liste qui révèle des actions au swipe. leftActions = actions à droite (révélées en swipant vers la gauche), rightActions = actions à gauche (révélées en swipant vers la droite). Chaque action a icon, label, color, onClick. Tap sur la ligne = onTap principal."
preview={
<SwipeableRow
leftActions={[{ label: 'Suppr.', icon: 'close', color: 'var(--err)' }]}
rightActions={[{ label: 'Lu', icon: 'play', color: 'var(--info)' }]}>
<div style={{ padding: 12, background: 'var(--bg-3)', fontSize: 13 }}>
swipe-moi dans un sens ou l'autre →
</div>
</SwipeableRow>
} />
</section>
{/* CLAVIER VIRTUEL */}
<section id="keyboard">
<h2><Icon name="terminal" size={22} style={{ color: 'var(--accent)' }} /> Clavier virtuel</h2>
<p className="desc">
Sur mobile, le clavier qui s'affiche dépend de la prop <code className="mono" style={{color:'var(--accent)'}}>keyboard</code> (attribut HTML <code className="mono">inputmode</code>).
Choisis le BON type pour faire gagner du temps à l'utilisateur — exemple : <code className="mono">keyboard="numeric"</code> pour un code OTP fait apparaître directement le pavé numérique au lieu du clavier complet.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{KEYBOARD_CATALOG.map((k) => (
<div key={k.name} className="card" style={{ margin: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="pill-name">{k.name}</span>
</div>
<KeyboardVisual kind={k.name} />
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 10, lineHeight: 1.4 }}>{k.desc}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontStyle: 'italic', marginTop: 4 }}>
Usage : {k.usage}
</div>
</div>
))}
</div>
</section>
{/* AUTOCOMPLETE */}
<section id="autocomplete">
<h2><Icon name="refresh" size={22} style={{ color: 'var(--accent)' }} /> Aide à la saisie (autocomplete)</h2>
<p className="desc">
L'attribut <code className="mono" style={{color:'var(--accent)'}}>autocomplete</code> dit au système ce que représente le champ.
Sur iOS/Android, ça déclenche : remplissage automatique (nom, email, adresse), proposition du mot de passe enregistré, génération d'un nouveau mot de passe, lecture auto du code SMS reçu.
</p>
<div className="card">
{AUTOCOMPLETE_CATALOG.map((a) => (
<div key={a.name} className="row-use">
<span className="k">{a.name}</span>
<span className="v">{a.usage}</span>
</div>
))}
</div>
</section>
{/* ENTER KEY HINT */}
<section id="enter-hint">
<h2><Icon name="chevR" size={22} style={{ color: 'var(--accent)' }} /> Touche Entrée — enterKeyHint</h2>
<p className="desc">
La touche en bas à droite du clavier peut afficher un mot différent selon le contexte (au lieu du standard "Entrée").
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{ENTER_HINT_CATALOG.map((e) => (
<div key={e.name} className="card" style={{ margin: 0, padding: 14 }}>
<div style={{
display: 'inline-block',
padding: '4px 12px', borderRadius: 6,
background: 'var(--accent)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
marginBottom: 8,
}}>{e.name}</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>{e.usage}</div>
</div>
))}
</div>
</section>
{/* CHEAT SHEET */}
<section id="cheatsheet">
<h2><Icon name="download" size={22} style={{ color: 'var(--accent)' }} /> Antisèche · combinaisons utiles</h2>
<div className="card">
<div className="row-use">
<span className="k">Email</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="email"</code> + <code className="mono">autocomplete="email"</code> + <code className="mono">autocapitalize="off"</code></span>
</div>
<div className="row-use">
<span className="k">Mot de passe</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>type="password"</code> + <code className="mono">autocomplete="current-password"</code> (ou <code className="mono">"new-password"</code> en inscription)</span>
</div>
<div className="row-use">
<span className="k">Code OTP SMS</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="numeric"</code> + <code className="mono">autocomplete="one-time-code"</code> + <code className="mono">maxLength=6</code></span>
</div>
<div className="row-use">
<span className="k">Téléphone</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="tel"</code> + <code className="mono">autocomplete="tel"</code></span>
</div>
<div className="row-use">
<span className="k">Recherche</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="search"</code> + <code className="mono">enterHint="search"</code></span>
</div>
<div className="row-use">
<span className="k">Prix / mesure</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>keyboard="decimal"</code></span>
</div>
<div className="row-use">
<span className="k">Adresse</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>autocomplete="address-line1"</code>, puis <code className="mono">postal-code</code>, <code className="mono">country</code></span>
</div>
<div className="row-use">
<span className="k">Texte libre</span>
<span className="v"><code className="mono" style={{color:'var(--accent)'}}>autocapitalize="sentences"</code> + <code className="mono">spellCheck=true</code></span>
</div>
</div>
</section>
</div>
);
}
/* ============================================================
APP ROOT
============================================================ */
function PhoneAppSaisie({ theme }) {
const [tab, setTab] = uDS('login');
const [toast, setToast] = uDS(null);
const [sheet, setSheet] = uDS(false);
const showToast = (msg) => setToast(msg);
return (
<div data-theme={theme} style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-1)', color: 'var(--ink-1)',
position: 'relative', overflow: 'hidden',
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{tab === 'login' && <ScreenLogin onLogin={() => setTab('profile')} showToast={showToast} />}
{tab === 'profile' && <ScreenProfile openSettings={() => setSheet(true)} />}
{tab === 'form' && <ScreenForm showToast={showToast} />}
{tab === 'swipe' && <ScreenSwipe showToast={showToast} />}
</div>
<TabBar
active={tab}
onSelect={setTab}
items={[
{ id: 'login', icon: 'user', label: 'login' },
{ id: 'profile', icon: 'cog', label: 'profil' },
{ id: 'form', icon: 'list', label: 'form' },
{ id: 'swipe', icon: 'chevR', label: 'notifs' },
]}
/>
<BottomSheet open={sheet} onClose={() => setSheet(false)} title="Paramètres rapides">
<ListSection>
<ListRow icon="moon" iconColor="var(--info)" label="Thème" value="Sombre" onClick={() => {}} />
<ListRow icon="bell" iconColor="var(--accent)" label="Notifications" right={<Toggle on={true} onChange={() => {}} />} />
<ListRow icon="refresh" iconColor="var(--ok)" label="Sync auto" right={<Toggle on={false} onChange={() => {}} />} />
</ListSection>
<ListSection>
<ListRow icon="power" iconColor="var(--err)" label="Se déconnecter" danger onClick={() => { setSheet(false); setTab('login'); }} />
</ListSection>
</BottomSheet>
<Toast open={toast !== null} onClose={() => setToast(null)} message={toast} variant="ok" />
</div>
);
}
function App() {
const [theme, setTheme] = uDS('dark');
const [device, setDevice] = uDS('ios');
eDS(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<React.Fragment>
<header className="page-top">
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--accent)', color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 6px var(--accent-glow)',
}}>
<Icon name="list" size={16} />
</div>
<h1>Exemple mobile · saisie <small>login · profil · form · swipe · clavier virtuel</small></h1>
<span style={{ flex: 1 }}></span>
<a href="exemple-mobile.html" style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--ink-3)', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}><Icon name="chevL" size={12} /> exemple mobile</a>
</header>
<div className="layout">
<div className="phone-col">
<div className="phone-controls">
<div className="seg">
<button className={device === 'ios' ? 'active' : ''} onClick={() => setDevice('ios')}>iOS</button>
<button className={device === 'android' ? 'active' : ''} onClick={() => setDevice('android')}>Android</button>
</div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Sombre</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Clair</button>
</div>
</div>
<div className="phone" style={device === 'android' ? { borderRadius: 28 } : {}}>
{device === 'ios' && <div className="phone-notch"></div>}
<div className="phone-screen" style={device === 'android' ? { borderRadius: 18 } : {}}>
<PhoneAppSaisie theme={theme} />
</div>
</div>
<div className="legend">↑ teste les écrans, swipe les lignes, joue avec les formulaires</div>
</div>
<Doc />
</div>
</React.Fragment>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — saisie & formulaires</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background: radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow: 0 0 0 2px #2a2520, 0 0 0 8px #1a1612, 0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907; border-radius: 18px;
z-index: 100; pointer-events: none;
}
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px; padding: 3px; gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px; background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3); cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em; text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent); color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
.doc { min-width: 0; }
.doc section { margin-bottom: 36px; scroll-margin-top: 80px; }
.doc h2 {
font-size: 22px; margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px; background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px; box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px; color: var(--accent); font-weight: 600;
}
.doc .row-use {
display: grid; grid-template-columns: 140px 1fr; gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel" src="mobile-swipeable.jsx"></script>
<script type="text/babel" src="mobile-forms.jsx"></script>
<script type="text/babel" src="exemple-mobile-saisie-app.jsx"></script>
<script type="text/babel" src="exemple-mobile-saisie-doc.jsx"></script>
</body>
</html>
@@ -0,0 +1,952 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Exemple mobile — mon design system</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="../tokens/tokens.css">
<style>
html, body {
width: 100%; min-height: 100%;
background:
radial-gradient(ellipse at top, var(--bg-2) 0%, var(--bg-1) 60%);
color: var(--ink-1);
overflow-x: hidden;
}
/* Topbar de la page */
.page-top {
position: sticky; top: 0; z-index: 80;
padding: 14px 20px;
background: var(--surf-glass-strong);
backdrop-filter: blur(14px) saturate(150%);
border-bottom: 1px solid var(--border-2);
display: flex; align-items: center; gap: 14px;
box-shadow: var(--shadow-2);
}
.page-top h1 { margin: 0; font-size: 17px; font-weight: 700; }
.page-top h1 small {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: 0.1em;
color: var(--ink-3); font-weight: 400;
margin-left: 8px; text-transform: uppercase;
}
/* Layout : 2 colonnes — phone à gauche, doc à droite */
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 32px;
padding: 32px;
max-width: 1400px;
margin: 0 auto;
align-items: start;
}
@media (max-width: 1000px) {
.layout { grid-template-columns: 1fr; padding: 20px; }
.phone-col { position: relative !important; top: auto !important; margin: 0 auto; }
}
/* Sticky phone */
.phone-col {
position: sticky; top: 80px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
/* Mockup smartphone */
.phone {
width: 390px; height: 780px;
background: #0c0907;
border-radius: 48px;
padding: 12px;
box-shadow:
0 0 0 2px #2a2520,
0 0 0 8px #1a1612,
0 20px 50px rgba(0,0,0,0.5);
position: relative;
}
.phone-screen {
width: 100%; height: 100%;
border-radius: 36px;
overflow: hidden;
background: var(--bg-1);
position: relative;
display: flex; flex-direction: column;
}
.phone-notch {
position: absolute; top: 12px; left: 50%;
transform: translateX(-50%);
width: 120px; height: 30px;
background: #0c0907;
border-radius: 18px;
z-index: 100;
pointer-events: none;
}
/* Phone controls */
.phone-controls {
display: flex; gap: 8px; align-items: center;
padding: 10px 14px;
background: var(--bg-3);
border: 1px solid var(--border-2);
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.phone-controls .seg {
display: flex; background: var(--bg-1);
border-radius: 999px;
padding: 3px;
gap: 2px;
}
.phone-controls .seg button {
padding: 4px 10px;
background: transparent;
border: none; border-radius: 999px;
color: var(--ink-3);
cursor: pointer;
font-family: var(--font-mono); font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.phone-controls .seg button.active {
background: var(--accent);
color: var(--bg-1);
box-shadow: 0 2px 6px var(--accent-glow);
}
/* Side doc */
.doc {
min-width: 0;
}
.doc section {
margin-bottom: 36px;
scroll-margin-top: 80px;
}
.doc h2 {
font-size: 22px;
margin: 0 0 4px;
display: flex; align-items: center; gap: 10px;
}
.doc h2 .name {
font-family: var(--font-mono);
color: var(--accent);
font-size: 18px;
}
.doc .desc {
color: var(--ink-3); font-size: 14px;
margin: 4px 0 16px; line-height: 1.55;
}
.doc .card {
padding: 18px;
background: var(--bg-2);
border: 1px solid var(--border-2);
border-radius: 12px;
box-shadow: var(--tile-3d);
margin-bottom: 12px;
}
.doc .pill-name {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
background: var(--accent-tint);
border: 1px solid var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
font-weight: 600;
}
.doc .row-use {
display: grid;
grid-template-columns: 130px 1fr;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed var(--border-1);
font-size: 13px;
}
.doc .row-use:last-child { border-bottom: 0; }
.doc .row-use .k {
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
.doc .row-use .v { color: var(--ink-2); }
.nav-jump {
position: sticky; top: 80px;
padding: 14px 0;
display: flex; flex-direction: column; gap: 4px;
font-family: var(--font-mono); font-size: 12px;
}
.nav-jump a {
padding: 6px 12px;
color: var(--ink-3);
text-decoration: none;
border-radius: 6px;
border-left: 3px solid transparent;
}
.nav-jump a:hover {
background: var(--bg-3); color: var(--ink-1);
border-left-color: var(--accent);
}
/* Légende — utilisé un peu partout */
.legend {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
color: var(--ink-3);
}
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ui-kit.jsx"></script>
<script type="text/babel" src="mobile-kit.jsx"></script>
<script type="text/babel" src="mobile-sheets.jsx"></script>
<script type="text/babel" src="mobile-gestures.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
/* ============================================================
ECRANS DU SMARTPHONE — chacun illustre un cas d'usage
============================================================ */
/* Écran ACCUEIL : ActionCards en grille + FAB */
function PhoneHome({ goto, showToast }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
<StatusBar />
<NavBar large title="Accueil" subtitle="jeudi 21 mai · tout est OK" right={
<IconButton icon="bell" label="Notifications" size={34} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: 16, paddingBottom: 80 }}>
<SearchBar value="" onChange={() => {}} />
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
marginTop: 14,
}}>
<ActionCard icon="cpu" iconColor="var(--accent)" title="Système" subtitle="CPU 64%" value="OK" />
<ActionCard icon="network" iconColor="var(--blue)" title="Réseau" subtitle="8.4 Mb/s" value="OK" />
<ActionCard icon="disk" iconColor="var(--ok)" title="Stockage" subtitle="2 disques" value="28%" />
<ActionCard icon="bell" iconColor="var(--warn)" title="Alertes" subtitle="2 nouvelles" badge="2" />
</div>
<div style={{ marginTop: 18 }}>
<div className="label" style={{ marginBottom: 8 }}>Services</div>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
}}>
{[
{ name: 'nginx', status: 'ok', meta: 'actif' },
{ name: 'postgres', status: 'ok', meta: 'actif' },
{ name: 'redis', status: 'warn', meta: 'latent' },
{ name: 'worker_01', status: 'err', meta: 'arrêté' },
].map((s, i, a) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
borderBottom: i < a.length - 1 ? '1px solid var(--border-1)' : 'none',
}}>
<StatusLed status={s.status} pulse={s.status !== 'ok'} />
<span className="mono" style={{ flex: 1, fontSize: 14, color: 'var(--ink-1)' }}>{s.name}</span>
<span className="mono" style={{ fontSize: 11, color: s.status === 'err' ? 'var(--err)' : s.status === 'warn' ? 'var(--warn)' : 'var(--ok)' }}>{s.meta}</span>
</div>
))}
</div>
</div>
</div>
<FAB icon="plus" label="Ajouter" onClick={() => showToast('Action FAB')} />
</div>
);
}
/* Écran DASHBOARD : KPIs + jauges */
function PhoneDashboard() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar title="Dashboard" />
<div style={{ flex: 1, overflowY: 'auto', padding: 14, paddingBottom: 80 }}>
{/* KPIs compacts */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
<BatteryGauge compact value={64} label="CPU" icon="cpu" warnAt={70} errAt={85} />
<BatteryGauge compact value={42} label="Mémoire" icon="memory" />
<BatteryGauge compact value={28} label="Disque" icon="disk" />
<BatteryGauge compact value={92} label="Réseau" icon="network" warnAt={70} errAt={85} />
</div>
{/* Grande jauge */}
<div style={{
padding: 14, background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12, boxShadow: 'var(--tile-3d)',
display: 'flex', flexDirection: 'column', alignItems: 'center',
marginBottom: 14,
}}>
<div className="label" style={{ alignSelf: 'flex-start', marginBottom: 8 }}>Score santé</div>
<BigRadialGauge value={87} label="stable" />
</div>
{/* Graphique */}
<div style={{
padding: 14, background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12, boxShadow: 'var(--tile-3d)',
}}>
<div className="label" style={{ marginBottom: 8 }}>Trafic · 24h</div>
<LineChart h={140} labels={[]} series={[
{ color: 'var(--accent)', points: [12,18,14,22,28,35,30,42,38,45,52,48,55,60,52,58,45,50,38,44,36,40,32,38] },
]} />
</div>
</div>
</div>
);
}
/* Écran RÉGLAGES : ListRow style iOS */
function PhoneSettings({ openSheet, openAlert }) {
const [auto, setAuto] = useState(true);
const [notif, setNotif] = useState(false);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Réglages" />
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0 80px' }}>
<ListSection title="Compte">
<ListRow icon="user" iconColor="var(--blue)" label="Marc" value="admin" onClick={() => {}} />
<ListRow icon="server" iconColor="var(--accent)" label="Instance" value="prod" onClick={() => {}} />
</ListSection>
<ListSection title="Notifications" hint="Choisis quand l'app doit te déranger.">
<ListRow icon="refresh" iconColor="var(--ok)" label="Auto-refresh"
right={<Toggle on={auto} onChange={setAuto} />} />
<ListRow icon="bell" iconColor="var(--purple)" label="Notifications push"
right={<Toggle on={notif} onChange={setNotif} />} />
</ListSection>
<ListSection title="Apparence">
<ListRow icon="moon" iconColor="var(--info)" label="Thème" value="Sombre" onClick={() => openSheet()} />
<ListRow icon="cog" iconColor="var(--ink-3)" label="Densité" value="Confort" onClick={() => {}} />
</ListSection>
<ListSection>
<ListRow icon="download" iconColor="var(--ok)" label="Exporter mes données" onClick={() => {}} />
<ListRow icon="power" iconColor="var(--err)" label="Supprimer mon compte" danger onClick={openAlert} />
</ListSection>
</div>
</div>
);
}
/* Écran GESTES : terrain de test pour chaque geste */
function PhoneGestures({ activeGesture, setActiveGesture }) {
const filter = activeGesture === 'all' ? [] : [activeGesture];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<StatusBar />
<NavBar large title="Gestes" subtitle="teste chaque interaction tactile" />
<div style={{ padding: '0 14px 12px' }}>
<SegmentedControl
value={activeGesture}
onChange={setActiveGesture}
options={[
{ value: 'all', label: 'tous' },
{ value: 'tap', label: 'tap', icon: 'play' },
{ value: 'swipe', label: 'swipe', icon: 'chevR' },
{ value: 'pan', label: 'drag', icon: 'grid' },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px 80px' }}>
<GestureZone label="zone tactile · essaie ici" accept={filter} />
<div className="legend" style={{ marginTop: 8, marginBottom: 6 }}> tap · double-tap · long-press · swipe · pan · pinch</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{GESTURE_CATALOG.map((g) => (
<div key={g.name} style={{
padding: 10,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-1)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<Icon name={g.icon} size={12} style={{ color: 'var(--accent)' }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--ink-1)' }}>{g.name}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', lineHeight: 1.3 }}>{g.desc}</div>
</div>
))}
</div>
</div>
</div>
);
}
/* ============================================================
APP COMPLÈTE DU PHONE — navigation par TabBar
============================================================ */
function PhoneApp({ theme }) {
const [tab, setTab] = useState('home');
const [sheet, setSheet] = useState(false);
const [alert, setAlert] = useState(false);
const [action, setAction] = useState(false);
const [toast, setToast] = useState(null);
const [activeGesture, setActiveGesture] = useState('all');
const [themeChoice, setThemeChoice] = useState('dark');
const showToast = (msg) => setToast(msg);
return (
<div data-theme={theme} style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
background: 'var(--bg-1)',
color: 'var(--ink-1)',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{tab === 'home' && <PhoneHome showToast={showToast} />}
{tab === 'dash' && <PhoneDashboard />}
{tab === 'gestures' && <PhoneGestures activeGesture={activeGesture} setActiveGesture={setActiveGesture} />}
{tab === 'settings' && <PhoneSettings openSheet={() => setSheet(true)} openAlert={() => setAlert(true)} />}
</div>
<TabBar
active={tab}
onSelect={setTab}
items={[
{ id: 'home', icon: 'grid', label: 'accueil' },
{ id: 'dash', icon: 'chart', label: 'dashboard' },
{ id: 'gestures', icon: 'play', label: 'gestes' },
{ id: 'settings', icon: 'cog', label: 'réglages' },
]}
/>
{/* BottomSheet : choix du thème (depuis Réglages > Thème) */}
<BottomSheet open={sheet} onClose={() => setSheet(false)} title="Choisir le thème">
<SegmentedControl
value={themeChoice}
onChange={setThemeChoice}
options={[
{ value: 'dark', label: 'Sombre', icon: 'moon' },
{ value: 'light', label: 'Clair', icon: 'sun' },
{ value: 'auto', label: 'Auto', icon: 'clock' },
]} />
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 14, lineHeight: 1.5 }}>
Le thème "Auto" suit automatiquement les réglages de ton téléphone (jour/nuit).
</div>
</BottomSheet>
{/* AlertDialog : confirmation destructive */}
<AlertDialog
open={alert} onClose={() => setAlert(false)}
icon="alert" iconColor="var(--err)"
title="Supprimer le compte ?"
message="Cette action est irréversible. Toutes tes données seront perdues."
actions={[
{ label: 'Annuler' },
{ label: 'Supprimer', danger: true, primary: true, onClick: () => showToast('Compte supprimé') },
]} />
{/* ActionSheet : ouverte depuis le FAB de l'accueil */}
<ActionSheet
open={action} onClose={() => setAction(false)}
title="Que veux-tu faire ?"
actions={[
{ label: 'Lancer un scan', icon: 'refresh', onClick: () => showToast('Scan lancé') },
{ label: 'Nouveau dashboard', icon: 'plus', onClick: () => showToast('Dashboard créé') },
{ label: 'Importer données', icon: 'download' },
{ label: 'Supprimer tout', icon: 'power', danger: true },
]} />
{/* Toast */}
<Toast open={toast !== null} onClose={() => setToast(null)} message={toast} variant="ok" />
</div>
);
}
/* ============================================================
PAGE DOC à droite — catalogue avec noms en clair
============================================================ */
function Doc({ currentScreen }) {
return (
<div className="doc">
{/* INTRO */}
<section>
<h2><Icon name="memory" size={22} style={{ color: 'var(--accent)' }} /> Variante mobile</h2>
<p className="desc">
Adaptation smartphone de mon design system (Gruvbox seventies).
<strong> Chaque composant a un nom explicite</strong> que tu peux utiliser pour
le demander à ton agent IA ou à un développeur. Hit targets 44px,
animations fluides, dark + light, optimisé iOS / Android.
</p>
<div className="card">
<div className="row-use"><span className="k">Largeur réf.</span><span className="v">390 px (iPhone 14, Galaxy S22)</span></div>
<div className="row-use"><span className="k">Hit target min.</span><span className="v">44 × 44 px (recommandation Apple/Google)</span></div>
<div className="row-use"><span className="k">Navigation</span><span className="v">TabBar en bas (3-5 sections)</span></div>
<div className="row-use"><span className="k">Action principale</span><span className="v">FAB bottom-right (Material) ou bouton plein largeur (iOS)</span></div>
<div className="row-use"><span className="k">Modales</span><span className="v">BottomSheet (priorité) · ActionSheet · AlertDialog</span></div>
</div>
</section>
{/* COMPOSANTS PHARES */}
<section id="components">
<h2><Icon name="grid" size={22} style={{ color: 'var(--accent)' }} /> Composants nommés</h2>
<p className="desc">Vois-les en vrai dans le téléphone à gauche. Le nom est ce que tu emploies dans le code.</p>
<NamedComp name="StatusBar" desc="Barre de statut iOS-like en haut de l'écran (heure, signal, batterie). Purement décorative." location="Tous les écrans" />
<NamedComp name="NavBar" desc="Barre de titre. Variante large pour écran d'accueil, ou compacte avec bouton retour pour écran enfant." location="Tous les écrans" />
<NamedComp name="TabBar" desc="Barre d'onglets en bas, 3-5 sections principales de l'app. C'est ta navigation primaire." location="Toujours visible" />
<NamedComp name="ActionCard" desc="Grande tuile tactile avec icône colorée + titre + valeur. Idéale en grille 2 colonnes pour un dashboard d'accueil." location="Accueil" />
<NamedComp name="ListSection / ListRow" desc="Liste de réglages style iOS. ListRow = une ligne (icône + label + valeur + chevron). Toute ligne fait ≥ 52px." location="Réglages" />
<NamedComp name="PrimaryButton" desc="Gros bouton 52px plein largeur. Variante primary, ghost, danger. Pour l'action principale d'un écran." location="Réglages > formulaires" />
<NamedComp name="SegmentedControl" desc="Sélecteur segmenté pour 2-4 options exclusives (jamais plus, sinon utilise un Select)." location="Gestes (filtre) · BottomSheet (choix thème)" />
<NamedComp name="SearchBar" desc="Champ de recherche avec icône loupe et bouton effacer. Padding tactile généreux." location="Accueil" />
<NamedComp name="FAB" desc="Floating Action Button. Toujours en bas à droite. Une seule action principale par écran. Style Android Material." location="Accueil" />
</section>
{/* FENÊTRES / DIALOGUES */}
<section id="windows">
<h2><Icon name="list" size={22} style={{ color: 'var(--accent)' }} /> Types de fenêtres</h2>
<p className="desc">Sur mobile, on évite les modales centrées. Voici les 4 types à utiliser à la place, chacun avec son cas.</p>
<WindowType
name="BottomSheet"
when="Action contextuelle, formulaire court, choix dans une liste."
why="Accessible au pouce, geste swipe down pour fermer, sensation native."
gesture="SwipeDown ↓ pour fermer · drag du handle en haut"
example="Sur ce smartphone : Réglages > Thème → ouvre une BottomSheet"
/>
<WindowType
name="ActionSheet"
when="Choix parmi 2-6 actions sur un élément (équiv. menu contextuel desktop)."
why="Style iOS natif, l'utilisateur sait que c'est une liste d'options."
gesture="Tap sur une option · Tap hors zone ou bouton Annuler pour fermer"
example="Tape le FAB orange sur l'accueil"
/>
<WindowType
name="AlertDialog"
when="Message critique, demande de confirmation ferme (suppression, déconnexion)."
why="Centré, bloque l'attention. À utiliser avec parcimonie."
gesture="Tap sur Annuler / Confirmer (pas de swipe pour fermer — c'est volontairement bloquant)"
example="Réglages > Supprimer mon compte"
/>
<WindowType
name="Toast"
when="Feedback éphémère après une action (succès, erreur)."
why="Non bloquant, disparaît seul après 2.5s."
gesture="Aucun — disparaît automatiquement"
example="Toute action ci-dessus déclenche un Toast en haut"
/>
</section>
{/* GESTES */}
<section id="gestures">
<h2><Icon name="play" size={22} style={{ color: 'var(--accent)' }} /> Gestes tactiles</h2>
<p className="desc">
Onglet <strong>Gestes</strong> en bas du smartphone zone interactive pour tester
chaque geste. Le nom du geste s'affiche en temps réel.
</p>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
marginBottom: 16,
}}>
{GESTURE_CATALOG.map((g) => (
<div key={g.name} className="card" style={{ padding: 14, margin: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Icon name={g.icon} size={14} style={{ color: 'var(--accent)' }} />
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>{g.name}</span>
</div>
<GestureAnim name={g.name} />
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 8 }}>{g.desc}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontStyle: 'italic', marginTop: 2 }}>
Usage : {g.usage}
</div>
</div>
))}
</div>
<div className="card">
<h3 style={{
margin: '0 0 8px', fontFamily: 'var(--font-mono)',
fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>Utilitaire</h3>
<div className="row-use">
<span className="k">useGesture()</span>
<span className="v">Hook React qui transforme un élément en zone tactile. Pose les handlers <code className="mono" style={{ color:'var(--accent)' }}>onTap / onSwipeLeft / onLongPress / onPinch</code> etc.</span>
</div>
<div className="row-use">
<span className="k">GestureZone</span>
<span className="v">Composant prêt-à-l'emploi qui affiche le geste détecté + un journal des 5 derniers. Utilisé dans l'onglet Gestes.</span>
</div>
</div>
</section>
{/* INSTALLATION */}
<section id="install">
<h2><Icon name="download" size={22} style={{ color: 'var(--accent)' }} /> Comment utiliser</h2>
<p className="desc">
Ajoute ces lignes en plus de <code className="mono" style={{ color:'var(--accent)' }}>ui-kit.jsx</code> :
</p>
<div className="card" style={{ background:'#15110c', padding: 16 }}>
<pre className="mono" style={{
margin: 0, fontSize: 12, lineHeight: 1.6, color: 'var(--ink-2)',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{`<scr` + `ipt type="text/babel" src="components/ui-kit.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-kit.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-sheets.jsx"></scr` + `ipt>
<scr` + `ipt type="text/babel" src="components/mobile-gestures.jsx"></scr` + `ipt>`}
</pre>
</div>
<p className="desc" style={{ marginTop: 16 }}>
Tu retrouves ensuite dans <code className="mono" style={{ color:'var(--accent)' }}>window</code> tous les composants exposés :
<strong> StatusBar, NavBar, TabBar, ListRow, ListSection, ActionCard, PrimaryButton, SegmentedControl, SearchBar,
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh, useGesture, GestureZone</strong>.
</p>
</section>
</div>
);
}
function NamedComp({ name, desc, location }) {
return (
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span className="pill-name">&lt;{name}/&gt;</span>
{location && <span className="legend">📍 {location}</span>}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-2)', lineHeight: 1.5 }}>{desc}</div>
<div style={{
marginTop: 12, padding: 12,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
minHeight: 72,
}}>
<ComponentPreview name={name} />
</div>
</div>
);
}
/* ============================================================
ComponentPreview — mini-rendu live de chaque composant nommé
============================================================ */
function ComponentPreview({ name }) {
// Réduit la taille via un wrapper compact
const wrap = (children, w = '100%') => (
<div style={{ width: w, maxWidth: 320 }}>{children}</div>
);
if (name === 'StatusBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><StatusBar /></div>);
if (name === 'NavBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><NavBar title="Mon écran" /></div>);
if (name === 'TabBar') return wrap(<div style={{ background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}><TabBar active="a" onSelect={() => {}} items={[
{ id: 'a', icon: 'grid', label: 'accueil' },
{ id: 'b', icon: 'chart', label: 'stats' },
{ id: 'c', icon: 'cog', label: 'réglages' },
]} /></div>);
if (name === 'ActionCard') return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, width: '100%', maxWidth: 280 }}>
<ActionCard icon="cpu" iconColor="var(--accent)" title="Système" subtitle="CPU 64%" value="OK" />
<ActionCard icon="bell" iconColor="var(--warn)" title="Alertes" subtitle="2 nouvelles" badge="2" />
</div>
);
if (name === 'ListSection / ListRow') return wrap(
<ListSection title="Notifications">
<ListRow icon="refresh" iconColor="var(--ok)" label="Auto-refresh" right={<Toggle on={true} onChange={() => {}} />} />
<ListRow icon="bell" iconColor="var(--purple)" label="Push" right={<Toggle on={false} onChange={() => {}} />} />
</ListSection>
);
if (name === 'PrimaryButton') return wrap(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<PrimaryButton icon="download">Enregistrer</PrimaryButton>
<PrimaryButton variant="ghost">Annuler</PrimaryButton>
</div>
);
if (name === 'SegmentedControl') return wrap(
<SegmentedControl value="a" onChange={() => {}} options={[
{ value: 'a', label: 'Sombre', icon: 'moon' },
{ value: 'b', label: 'Clair', icon: 'sun' },
{ value: 'c', label: 'Auto', icon: 'clock' },
]} />
);
if (name === 'SearchBar') return wrap(<SearchBar value="" onChange={() => {}} placeholder="rechercher…" />);
if (name === 'FAB') return (
<div style={{ position: 'relative', width: 220, height: 90, background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ position: 'absolute', inset: 0, padding: 10, color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>écran…</div>
<div style={{ position: 'absolute', bottom: 10, right: 10 }}>
<button className="touch-press" style={{
width: 48, height: 48, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 14px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25)',
}}><Icon name="plus" size={20} /></button>
</div>
</div>
);
return null;
}
function WindowType({ name, when, why, gesture, example }) {
return (
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr', gap: 14, alignItems: 'flex-start' }}>
<WindowVisual type={name} />
<div style={{ minWidth: 0 }}>
<span className="pill-name" style={{ marginBottom: 10, display: 'inline-flex' }}>&lt;{name}/&gt;</span>
<div className="row-use"><span className="k">Quand</span><span className="v">{when}</span></div>
<div className="row-use"><span className="k">Pourquoi</span><span className="v">{why}</span></div>
<div className="row-use"><span className="k">Gestes</span><span className="v">{gesture}</span></div>
<div className="row-use"><span className="k">Tester</span><span className="v" style={{ color:'var(--accent)' }}>{example}</span></div>
</div>
</div>
</div>
);
}
/* ============================================================
WindowVisual — mini SVG phone + zone modale colorée
============================================================ */
function WindowVisual({ type }) {
const phone = (inner) => (
<svg viewBox="0 0 100 180" width="80" height="144" style={{ display: 'block', margin: '0 auto' }}>
{/* Cadre téléphone */}
<rect x="3" y="2" width="94" height="176" rx="14" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
{/* indication contenu */}
<rect x="10" y="18" width="50" height="3" rx="1.5" fill="var(--ink-4)" opacity="0.5"/>
<rect x="10" y="26" width="60" height="2" rx="1" fill="var(--ink-4)" opacity="0.3"/>
<rect x="10" y="32" width="40" height="2" rx="1" fill="var(--ink-4)" opacity="0.3"/>
{inner}
</svg>
);
if (type === 'BottomSheet') return phone(
<g>
<rect x="6" y="108" width="88" height="68" rx="8" fill="var(--accent)" opacity="0.92"/>
<rect x="44" y="114" width="12" height="2.5" rx="1.25" fill="var(--bg-1)"/>
<path d="M 50 145 v 14 M 46 155 l 4 5 l 4 -5" stroke="var(--bg-1)" strokeWidth="1.5" fill="none" opacity="0.7"/>
</g>
);
if (type === 'ActionSheet') return phone(
<g>
<rect x="6" y="108" width="88" height="50" rx="6" fill="var(--accent)" opacity="0.85"/>
<line x1="10" y1="122" x2="90" y2="122" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="10" y1="135" x2="90" y2="135" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="10" y1="148" x2="90" y2="148" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<rect x="6" y="162" width="88" height="14" rx="6" fill="var(--bg-1)" stroke="var(--accent)" strokeWidth="1"/>
<text x="50" y="172" textAnchor="middle" fill="var(--accent)" fontSize="7" fontFamily="Inter" fontWeight="700">Annuler</text>
</g>
);
if (type === 'AlertDialog') return phone(
<g>
<rect x="0" y="0" width="100" height="180" fill="#000" opacity="0.45"/>
<rect x="3" y="2" width="94" height="176" rx="14" fill="none" stroke="var(--border-3)" strokeWidth="1.5"/>
<rect x="38" y="6" width="24" height="3" rx="1.5" fill="var(--ink-4)"/>
<rect x="16" y="66" width="68" height="56" rx="8" fill="var(--err)" opacity="0.92"/>
<circle cx="50" cy="82" r="6" fill="var(--bg-1)" opacity="0.95"/>
<line x1="30" y1="96" x2="70" y2="96" stroke="var(--bg-1)" strokeWidth="1.4" opacity="0.85"/>
<line x1="36" y1="102" x2="64" y2="102" stroke="var(--bg-1)" strokeWidth="1" opacity="0.6"/>
<line x1="16" y1="112" x2="84" y2="112" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
<line x1="50" y1="112" x2="50" y2="122" stroke="var(--bg-1)" strokeWidth="0.5" opacity="0.4"/>
</g>
);
if (type === 'Toast') return phone(
<g>
<rect x="8" y="18" width="84" height="14" rx="7" fill="var(--ok)" opacity="0.95"/>
<circle cx="16" cy="25" r="2.5" fill="var(--bg-1)"/>
<line x1="22" y1="25" x2="80" y2="25" stroke="var(--bg-1)" strokeWidth="1.5" opacity="0.7"/>
</g>
);
return phone(null);
}
/* ============================================================
GestureAnim — animation SVG par geste
============================================================ */
function GestureAnim({ name }) {
const sty = {
width: '100%', height: 80,
background: 'var(--bg-1)',
border: '1px dashed var(--border-2)',
borderRadius: 8,
};
const dot = (cx, cy, r = 6) => <circle cx={cx} cy={cy} r={r} fill="var(--accent)" />;
const trail = (path) => (
<path d={path} stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3" fill="none" />
);
const arrow = (x, y, dir) => {
const v = { l: 'l 5 -4 m -5 4 l 5 4', r: 'l -5 -4 m 5 4 l -5 4', u: 'l -4 5 m 4 -5 l 4 5', d: 'l -4 -5 m 4 5 l 4 -5' }[dir];
return <path d={`M ${x} ${y} ${v}`} stroke="var(--ink-3)" strokeWidth="1.2" fill="none" strokeLinecap="round"/>;
};
if (name === 'Tap') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;22;6" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0;0.9" dur="1.6s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
</svg>
);
if (name === 'DoubleTap') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;14;6;14;6" keyTimes="0;0.15;0.3;0.45;1" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0;0.9;0;0.9" keyTimes="0;0.15;0.3;0.45;1" dur="1.8s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
</svg>
);
if (name === 'LongPress') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle cx="50" cy="30" r="6" fill="none" stroke="var(--accent)" strokeWidth="2">
<animate attributeName="r" values="6;24" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;0" dur="2s" repeatCount="indefinite"/>
</circle>
{dot(50, 30)}
<text x="50" y="54" textAnchor="middle" fontSize="7" fontFamily="JetBrains Mono" fill="var(--ink-3)">500ms</text>
</svg>
);
if (name === 'SwipeLeft') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 78 30 L 22 30')}
{arrow(22, 30, 'l')}
<circle r="6" fill="var(--accent)">
<animate attributeName="cx" values="78;22" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="cy" values="30;30" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeRight') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 22 30 L 78 30')}
{arrow(78, 30, 'r')}
<circle r="6" fill="var(--accent)">
<animate attributeName="cx" values="22;78" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeUp') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 50 52 L 50 10')}
{arrow(50, 10, 'u')}
<circle r="6" fill="var(--accent)" cx="50">
<animate attributeName="cy" values="52;10" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'SwipeDown') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
{trail('M 50 10 L 50 52')}
{arrow(50, 52, 'd')}
<circle r="6" fill="var(--accent)" cx="50">
<animate attributeName="cy" values="10;52" dur="1.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.85;1" dur="1.6s" repeatCount="indefinite"/>
</circle>
</svg>
);
if (name === 'Pan') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<path d="M 20 45 Q 35 8 50 30 T 80 18" stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3" fill="none"/>
<circle r="6" fill="var(--accent)">
<animateMotion dur="2s" repeatCount="indefinite" path="M 20 45 Q 35 8 50 30 T 80 18"/>
</circle>
</svg>
);
if (name === 'Pinch') return (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" style={sty}>
<circle r="5" fill="var(--accent)" cy="30">
<animate attributeName="cx" values="30;46;30" dur="1.8s" repeatCount="indefinite"/>
</circle>
<circle r="5" fill="var(--accent)" cy="30">
<animate attributeName="cx" values="70;54;70" dur="1.8s" repeatCount="indefinite"/>
</circle>
<line y1="30" y2="30" stroke="var(--ink-4)" strokeWidth="0.8" strokeDasharray="3 3">
<animate attributeName="x1" values="30;46;30" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="x2" values="70;54;70" dur="1.8s" repeatCount="indefinite"/>
</line>
</svg>
);
return null;
}
/* ============================================================
ROOT
============================================================ */
function App() {
const [theme, setTheme] = useState('dark');
const [device, setDevice] = useState('ios');
useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
return (
<>
<header className="page-top">
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--accent)', color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 6px var(--accent-glow)',
}}>
<Icon name="memory" size={16} />
</div>
<h1>Exemple mobile <small>composants nommés · gestes testables · v1.0</small></h1>
<span style={{ flex: 1 }}></span>
<a href="exemple-tout.html" style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--ink-3)', textDecoration: 'none',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
<Icon name="chevL" size={12} /> exemple desktop
</a>
</header>
<div className="layout">
<div className="phone-col">
<div className="phone-controls">
<div className="seg">
<button className={device === 'ios' ? 'active' : ''} onClick={() => setDevice('ios')}>iOS</button>
<button className={device === 'android' ? 'active' : ''} onClick={() => setDevice('android')}>Android</button>
</div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Sombre</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Clair</button>
</div>
</div>
<div className="phone" style={device === 'android' ? { borderRadius: 28 } : {}}>
{device === 'ios' && <div className="phone-notch"></div>}
<div className="phone-screen" style={device === 'android' ? { borderRadius: 18 } : {}}>
<PhoneApp theme={theme} />
</div>
</div>
<div className="legend">↑ utilise le smartphone comme un vrai téléphone</div>
</div>
<Doc />
</div>
</>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
@@ -0,0 +1,659 @@
/* ============================================================
mobile-apps.jsx
Composants pour patterns d'app courants : avatar+menu,
onboarding, chat, calendrier, maps, recherche+filtres,
scanner QR, caméra, gestion fichiers.
============================================================ */
const { useState: uA, useRef: rA, useEffect: eA } = React;
/* ============================================================
Avatar — bouton rond utilisateur (initiales ou icône)
Nom système : Avatar
============================================================ */
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<button onClick={onClick} className="touch-press" style={{
width: size, height: size, borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
color: 'var(--bg-1)',
border: active ? '2px solid var(--accent)' : 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
WebkitTapHighlightColor: 'transparent',
}}>{initials}</button>
);
}
/* ============================================================
AvatarMenu — popup descendant depuis l'avatar
Nom système : AvatarMenu
Items : [{icon, label, onClick, danger}]
============================================================ */
function AvatarMenu({ open, onClose, name, email, items = [] }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.35)',
animation: 'fade-in .15s',
}}>
<style>{`
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
position: 'absolute', top: 56, right: 12,
width: 240,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
transformOrigin: 'top right',
}}>
<div style={{
padding: '14px 14px 12px',
display: 'flex', alignItems: 'center', gap: 10,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-2)',
}}>
<Avatar name={name} size={36} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
</div>
</div>
{items.map((it, i) => (
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
className="touch-press" style={{
width: '100%', minHeight: 44,
padding: '10px 14px',
background: 'transparent', border: 'none',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
display: 'flex', alignItems: 'center', gap: 10,
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
cursor: 'pointer', textAlign: 'left',
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
<span style={{ flex: 1 }}>{it.label}</span>
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
</button>
))}
</div>
</div>
);
}
/* ============================================================
OnboardingSlider — slides + dots + boutons suivant/passer
Nom système : OnboardingSlider
Cas : présentation d'une nouvelle app à l'utilisateur.
slides : [{icon, color, title, desc}]
============================================================ */
function OnboardingSlider({ slides, onFinish }) {
const [i, setI] = uA(0);
const isLast = i === slides.length - 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '14px 20px',
display: 'flex', justifyContent: 'flex-end',
}}>
<button onClick={onFinish} style={{
padding: '6px 12px', background: 'transparent', border: 'none',
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>Passer</button>
</div>
<div style={{
flex: 1, padding: '0 32px',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
textAlign: 'center',
}}>
<div style={{
width: 110, height: 110, borderRadius: 28,
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 28,
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
}}>
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
<Icon name={slides[i].icon} size={56} />
</div>
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
</div>
<div style={{ padding: '20px 24px 30px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, j) => (
<span key={j} onClick={() => setI(j)} style={{
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
background: i === j ? 'var(--accent)' : 'var(--border-3)',
transition: 'width .25s, background .2s',
cursor: 'pointer',
}} />
))}
</div>
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
onClick={() => isLast ? onFinish() : setI(i + 1)}>
{isLast ? 'Commencer' : 'Suivant'}
</PrimaryButton>
</div>
</div>
);
}
/* ============================================================
ChatBubble — bulle de message (envoyé/reçu)
Nom système : ChatBubble
============================================================ */
function ChatBubble({ text, time, me, status }) {
return (
<div style={{
display: 'flex',
justifyContent: me ? 'flex-end' : 'flex-start',
padding: '4px 14px',
}}>
<div style={{
maxWidth: '78%',
padding: '8px 12px',
background: me ? 'var(--accent)' : 'var(--bg-3)',
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
fontSize: 14, lineHeight: 1.4,
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
border: me ? 'none' : '1px solid var(--border-2)',
}}>
<div>{text}</div>
<div style={{
fontSize: 10,
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
marginTop: 4, textAlign: 'right',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: 4,
float: 'right',
}}>
{time}
{me && status === 'sent' && <span></span>}
{me && status === 'read' && <span></span>}
</div>
</div>
</div>
);
}
/* ============================================================
ChatComposer — barre d'envoi en bas (input + + + send)
Nom système : ChatComposer
============================================================ */
function ChatComposer({ onSend }) {
const [v, setV] = uA('');
return (
<div style={{
padding: '8px 10px 18px',
display: 'flex', alignItems: 'flex-end', gap: 8,
borderTop: '1px solid var(--border-2)',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px)',
}}>
<IconButton icon="plus" label="Joindre" size={36} />
<div style={{
flex: 1, minHeight: 36,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
}}>
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
placeholder="Message…"
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
}} />
</div>
{v ? (
<button onClick={() => { onSend && onSend(v); setV(''); }}
className="touch-press" style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="chevR" size={16} /></button>
) : (
<IconButton icon="terminal" label="Audio" size={36} />
)}
</div>
);
}
/* ============================================================
CalendarMonth — vue mois avec points sous les jours marqués
Nom système : CalendarMonth
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
============================================================ */
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
const today = new Date();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startDay = (first.getDay() + 6) % 7; // lundi = 0
const days = last.getDate();
const cells = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= days; d++) cells.push(d);
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 14px 12px',
}}>
<IconButton icon="chevL" label="Mois précédent" size={32} />
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
<IconButton icon="chevR" label="Mois suivant" size={32} />
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
padding: '0 8px',
}}>
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
<div key={i} style={{
textAlign: 'center', fontSize: 10,
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
fontWeight: 700, padding: '4px 0',
letterSpacing: '0.08em',
}}>{d}</div>
))}
{cells.map((d, i) => {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
const hasEvent = d && events.has(d);
return (
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
disabled={!d}
className="touch-press"
style={{
aspectRatio: '1',
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
border: 'none', borderRadius: 8,
fontFamily: 'var(--font-mono)', fontSize: 13,
fontWeight: isSel || isToday ? 700 : 500,
cursor: d ? 'pointer' : 'default',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
{d}
{hasEvent && (
<span style={{
position: 'absolute', bottom: 4, left: '50%',
transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
}}/>
)}
</button>
);
})}
</div>
</div>
);
}
/* ============================================================
MapView — placeholder visuel d'une carte avec pins
Nom système : MapView
============================================================ */
function MapView({ pins = [] }) {
return (
<div style={{
position: 'relative',
height: '100%', width: '100%',
background: 'var(--bg-2)',
overflow: 'hidden',
}}>
{/* fond carte stylisé */}
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
{/* routes */}
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
{/* zones */}
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
{/* fleuve */}
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
</svg>
{/* pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
background: p.color || 'var(--accent)',
transform: 'rotate(-45deg)',
border: '2px solid var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
}}>
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
</div>
{p.label && (
<div style={{
position: 'absolute', top: -28, left: '50%',
transform: 'translateX(-50%)',
padding: '3px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 6,
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
}}>{p.label}</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
FilterChips — barre de chips de filtre
Nom système : FilterChips
============================================================ */
function FilterChips({ value = [], onChange, options }) {
const toggle = (v) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
return (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'object' ? o.icon : null;
const active = value.includes(v);
return (
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
flex: '0 0 auto',
padding: '6px 12px',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', gap: 6,
cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={12} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
QrScannerView — viseur scanner code-barres / QR
Nom système : QrScannerView
============================================================ */
function QrScannerView({ onCapture }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000',
overflow: 'hidden',
}}>
{/* fake camera feed = grain animé */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
#15110c
`,
}}/>
{/* visée centrale */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 220, height: 220,
}}>
{/* 4 coins */}
{[
{ top: 0, left: 0, br: '4px 0 0 0' },
{ top: 0, right: 0, br: '0 4px 0 0' },
{ bottom: 0, left: 0, br: '0 0 0 4px' },
{ bottom: 0, right: 0, br: '0 0 4px 0' },
].map((c, i) => (
<div key={i} style={{
position: 'absolute', ...c, width: 28, height: 28,
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
borderRadius: c.br,
}}/>
))}
{/* ligne scan animée */}
<div style={{
position: 'absolute', left: 6, right: 6, height: 2,
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
animation: 'qr-scan 2.4s ease-in-out infinite',
}}/>
<style>{`@keyframes qr-scan {
0%, 100% { top: 6px; opacity: 1 }
50% { top: calc(100% - 8px); opacity: 0.7 }
}`}</style>
</div>
{/* overlay assombri hors visée */}
<div style={{
position: 'absolute', inset: 0,
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
pointerEvents: 'none',
}}/>
{/* texte */}
<div style={{
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
textAlign: 'center', color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>Pointe vers un QR code ou code-barres</div>
{/* boutons bas */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<IconButton icon="folder" label="Galerie" size={44} />
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
width: 70, height: 70, borderRadius: '50%',
background: 'var(--accent)', border: '4px solid #fff',
color: 'var(--bg-1)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="grid" size={26} /></button>
<IconButton icon="moon" label="Flash" size={44} />
</div>
</div>
);
}
/* ============================================================
CameraView — viseur appareil photo avec shutter rond
Nom système : CameraView
============================================================ */
function CameraView({ onShoot }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000', overflow: 'hidden',
}}>
{/* fake scene */}
<div style={{
position: 'absolute', inset: 0,
background: `
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
`,
backgroundBlendMode: 'overlay',
}}/>
{/* règle des tiers */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{[33.33, 66.66].map((p) => (
<React.Fragment key={p}>
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
</React.Fragment>
))}
</div>
{/* top bar */}
<div style={{
position: 'absolute', top: 20, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around',
padding: '0 16px',
}}>
{[
{ icon: 'moon', label: 'Flash' },
{ icon: 'clock', label: 'Minuteur' },
{ icon: 'grid', label: 'Grille' },
].map((b) => (
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
))}
</div>
{/* mode chips */}
<div style={{
position: 'absolute', bottom: 130, left: 0, right: 0,
display: 'flex', justifyContent: 'center', gap: 20,
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
<span style={{ opacity: 0.5 }}>Vidéo</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
<span style={{ opacity: 0.5 }}>Portrait</span>
</div>
{/* bottom controls */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<div style={{
width: 50, height: 50, borderRadius: 10,
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
border: '2px solid #fff',
}}/>
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
width: 76, height: 76, borderRadius: '50%',
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
cursor: 'pointer',
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
WebkitTapHighlightColor: 'transparent',
}}/>
<IconButton icon="refresh" label="Caméra avant" size={44} />
</div>
</div>
);
}
/* ============================================================
FileExplorer — liste fichiers/dossiers
Nom système : FileExplorer
============================================================ */
function FileExplorer({ items, onOpen, onAction }) {
const sizeFmt = (b) => {
if (b == null) return '';
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
};
const typeIcon = (t) => ({
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
})[t] || 'list';
const typeColor = (t) => ({
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
})[t] || 'var(--ink-3)';
return (
<div>
{items.map((it) => (
<SwipeableRow key={it.name}
onTap={() => onOpen && onOpen(it)}
leftActions={[
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => onAction && onAction('delete', it) },
]}
rightActions={[
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
onClick: () => onAction && onAction('rename', it) },
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
onClick: () => onAction && onAction('share', it) },
]}>
<div style={{
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 38, height: 38, borderRadius: 8,
background: 'var(--bg-1)',
border: `1px solid ${typeColor(it.type)}`,
color: typeColor(it.type),
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={typeIcon(it.type)} size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
</div>
</div>
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
</div>
</SwipeableRow>
))}
</div>
);
}
Object.assign(window, {
Avatar, AvatarMenu,
OnboardingSlider,
ChatBubble, ChatComposer,
CalendarMonth,
MapView,
FilterChips,
QrScannerView, CameraView,
FileExplorer,
});
@@ -0,0 +1,385 @@
/* ============================================================
mobile-forms.jsx
Composants de saisie mobile avec contrôle du clavier virtuel.
Tous nommés et exposés sur window.
============================================================ */
const { useState: uMF, useRef: rMF } = React;
/* ============================================================
FormField — wrapper standard pour un champ
Nom système : FormField
Affiche : label · description · le champ · message d'erreur/hint
============================================================ */
function FormField({ label, hint, error, required, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
{label && (
<label style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>
{label}{required && <span style={{ color: 'var(--accent)', marginLeft: 4 }}>*</span>}
</label>
)}
{children}
{(error || hint) && (
<div style={{
fontSize: 12,
color: error ? 'var(--err)' : 'var(--ink-4)',
lineHeight: 1.4,
}}>{error || hint}</div>
)}
</div>
);
}
/* ============================================================
TextInput — champ texte avec contrôle complet du clavier virtuel
Nom système : TextInput
Props clavier virtuel (mobile uniquement) :
keyboard: 'text' | 'numeric' | 'tel' | 'email' | 'url' | 'search' | 'decimal' | 'none'
autocomplete: 'name'|'email'|'tel'|'address-line1'|'postal-code'|'country'|
'given-name'|'family-name'|'current-password'|'new-password'|
'one-time-code'|'off'… (Web Authentication API)
autocapitalize: 'sentences' | 'words' | 'characters' | 'off'
spellCheck: bool
enterHint: 'send'|'search'|'go'|'done'|'next'|'previous' (texte de la touche Entrée)
pattern: regex de validation
============================================================ */
function TextInput({
value, onChange, placeholder, type = 'text', icon, trailing,
keyboard, autocomplete = 'off', autocapitalize = 'sentences',
spellCheck = false, enterHint, pattern, maxLength, multiline, rows = 4,
error,
}) {
const C = multiline ? 'textarea' : 'input';
const inputProps = {
value, onChange: (e) => onChange(e.target.value),
placeholder,
inputMode: keyboard,
autoComplete: autocomplete,
autoCapitalize: autocapitalize,
spellCheck,
enterKeyHint: enterHint,
pattern, maxLength,
rows: multiline ? rows : undefined,
type: !multiline ? type : undefined,
style: {
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: type === 'password' ? 'var(--font-mono)' : 'var(--font-ui)',
fontSize: 15,
padding: multiline ? '4px 0' : 0,
resize: multiline ? 'vertical' : undefined,
minHeight: multiline ? rows * 22 : undefined,
},
};
return (
<div style={{
display: 'flex', alignItems: multiline ? 'flex-start' : 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: `1px solid ${error ? 'var(--err)' : 'var(--border-2)'}`,
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{icon && <Icon name={icon} size={16} style={{ color: 'var(--ink-3)', flex: '0 0 auto', marginTop: multiline ? 4 : 0 }} />}
<C {...inputProps} />
{trailing}
</div>
);
}
/* ============================================================
DateInput — date picker natif mobile
Nom système : DateInput
============================================================ */
function DateInput({ value, onChange, mode = 'date' }) {
// mode : 'date' | 'datetime-local' | 'time' | 'month' | 'week'
const icons = { date: 'clock', 'datetime-local': 'clock', time: 'clock', month: 'clock', week: 'clock' };
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name={icons[mode]} size={16} style={{ color: 'var(--accent)' }} />
<input
type={mode}
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)',
fontFamily: 'var(--font-mono)', fontSize: 15,
colorScheme: 'dark',
}}
/>
</div>
);
}
/* ============================================================
Dropdown — select natif stylisé
Nom système : Dropdown
============================================================ */
function Dropdown({ value, onChange, options, placeholder = 'Choisir…' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
position: 'relative',
}}>
<select value={value} onChange={(e) => onChange(e.target.value)} style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: value ? 'var(--ink-1)' : 'var(--ink-3)',
fontFamily: 'var(--font-ui)', fontSize: 15,
appearance: 'none', WebkitAppearance: 'none',
paddingRight: 24,
}}>
<option value="">{placeholder}</option>
{options.map((o) => (
typeof o === 'string'
? <option key={o} value={o}>{o}</option>
: <option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Icon name="chevD" size={14} style={{ color: 'var(--ink-3)', position: 'absolute', right: 14, pointerEvents: 'none' }} />
</div>
);
}
/* ============================================================
CheckboxItem — case à cocher (style iOS)
Nom système : CheckboxItem
Cas : oui/non sur une option, sélection multiple dans une liste
============================================================ */
function CheckboxItem({ checked, onChange, label, description }) {
return (
<label className="touch-press" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '12px 14px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: checked ? 'var(--accent)' : 'var(--bg-1)',
border: `1.5px solid ${checked ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--bg-1)',
flex: '0 0 auto', marginTop: 1,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
transition: 'all .12s',
}}>
{checked && <Icon name="play" size={11} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{label}</div>
{description && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{description}</div>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} style={{ display: 'none' }} />
</label>
);
}
/* ============================================================
RadioGroup — groupe d'options exclusives
Nom système : RadioGroup
============================================================ */
function RadioGroup({ value, onChange, options }) {
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
overflow: 'hidden',
}}>
{options.map((o, i) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const d = typeof o === 'object' ? o.description : null;
const active = value === v;
return (
<label key={v} className="touch-press" style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 22, height: 22, borderRadius: '50%',
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-3)'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
background: 'var(--bg-1)',
}}>
{active && <span style={{ width: 10, height: 10, borderRadius: '50%', background: 'var(--accent)' }} />}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, color: 'var(--ink-1)', fontWeight: 500 }}>{l}</div>
{d && <div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>{d}</div>}
</div>
<input type="radio" checked={active} onChange={() => onChange(v)} style={{ display: 'none' }} />
</label>
);
})}
</div>
);
}
/* ============================================================
MediaInsert — boutons "insérer..." pour image/vidéo/audio/GPS
Nom système : MediaInsert
Cas : ajouter une pièce jointe dans un formulaire mobile.
Note : utilise les API natives via <input type="file" accept="..." capture="..."/>
et navigator.geolocation pour le GPS.
============================================================ */
function MediaInsert({ onPick }) {
const items = [
{ id: 'photo', icon: 'grid', label: 'Photo', hint: 'Appareil photo', accept: 'image/*', capture: 'environment' },
{ id: 'image', icon: 'folder', label: 'Image', hint: 'Depuis la galerie', accept: 'image/*' },
{ id: 'video', icon: 'play', label: 'Vidéo', hint: 'Caméra ou galerie', accept: 'video/*', capture: 'environment' },
{ id: 'audio', icon: 'terminal', label: 'Audio', hint: 'Enregistrement vocal', accept: 'audio/*', capture: 'user' },
{ id: 'file', icon: 'download', label: 'Fichier', hint: 'Doc, PDF, autre', accept: '*' },
{ id: 'gps', icon: 'network', label: 'Position', hint: 'GPS du téléphone', special: true },
];
return (
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8,
}}>
{items.map((it) => (
<label key={it.id} className="touch-press" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, padding: '14px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
color: 'var(--ink-1)',
cursor: 'pointer',
textAlign: 'center',
WebkitTapHighlightColor: 'transparent',
minHeight: 72,
}}>
<Icon name={it.icon} size={18} style={{ color: 'var(--accent)' }} />
<span style={{ fontSize: 12, fontWeight: 600 }}>{it.label}</span>
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>{it.hint}</span>
{!it.special && (
<input type="file" accept={it.accept} capture={it.capture}
onChange={(e) => onPick && onPick(it.id, e.target.files[0])}
style={{ display: 'none' }} />
)}
{it.special && (
<input type="button" onClick={() => {
if (!navigator.geolocation) { onPick && onPick('gps', { error: 'GPS indisponible' }); return; }
navigator.geolocation.getCurrentPosition(
(pos) => onPick && onPick('gps', { lat: pos.coords.latitude, lon: pos.coords.longitude }),
(err) => onPick && onPick('gps', { error: err.message }),
);
}} style={{ display: 'none' }} />
)}
</label>
))}
</div>
);
}
/* ============================================================
AvatarLogo — gros logo rond pour écran de connexion
Nom système : AvatarLogo
============================================================ */
function AvatarLogo({ icon = 'server', size = 80, glow = true }) {
return (
<div style={{
width: size, height: size, borderRadius: size * 0.28,
background: `linear-gradient(135deg, var(--accent), color-mix(in oklch, var(--accent) 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: glow
? `inset 0 2px 0 rgba(255,255,255,0.25), 0 8px 24px var(--accent-glow), 0 4px 12px rgba(0,0,0,0.4)`
: 'inset 0 2px 0 rgba(255,255,255,0.25)',
margin: '0 auto',
}}>
<Icon name={icon} size={size * 0.45} />
</div>
);
}
/* ============================================================
BiometricButton — bouton biométrie (Face ID / Touch ID)
Nom système : BiometricButton
============================================================ */
function BiometricButton({ kind = 'face', label, onClick }) {
const lbl = label || (kind === 'face' ? 'Face ID' : 'Touch ID');
return (
<button onClick={onClick} className="touch-press" style={{
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
padding: '8px 14px',
background: 'transparent', border: 'none',
color: 'var(--accent)', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={kind === 'face' ? 'user' : 'play'} size={28} />
{lbl}
</button>
);
}
Object.assign(window, {
FormField, TextInput, DateInput, Dropdown,
CheckboxItem, RadioGroup, MediaInsert,
AvatarLogo, BiometricButton,
});
/* ============================================================
CATALOGUE KEYBOARD — pour la doc
============================================================ */
const KEYBOARD_CATALOG = [
{ name: 'text', desc: 'Clavier standard (lettres + chiffres).', usage: 'Tout texte libre, noms.' },
{ name: 'numeric', desc: 'Pavé numérique sans signe ni virgule.', usage: 'Codes PIN, OTP, références numériques.' },
{ name: 'decimal', desc: 'Pavé numérique avec virgule/point.', usage: 'Prix, mesures, montants.' },
{ name: 'tel', desc: 'Pavé téléphone avec + et formats.', usage: 'Numéros de téléphone.' },
{ name: 'email', desc: 'Clavier texte avec @ et . en accès direct.', usage: 'Adresses email.' },
{ name: 'url', desc: 'Clavier texte avec / et .com.', usage: 'URLs, liens.' },
{ name: 'search', desc: 'Clavier standard, touche Entrée = "Rechercher".', usage: 'Champs de recherche.' },
{ name: 'none', desc: 'Aucun clavier (utile avec un picker custom).', usage: 'Date picker custom, sélecteur de couleur, etc.' },
];
const AUTOCOMPLETE_CATALOG = [
{ name: 'name / given-name / family-name', usage: 'Nom complet, prénom, nom de famille' },
{ name: 'email', usage: 'Adresse email (autoremplie depuis le compte iOS/Android)' },
{ name: 'tel', usage: 'Numéro de téléphone' },
{ name: 'address-line1 / postal-code / country', usage: 'Adresse postale' },
{ name: 'current-password', usage: 'Mot de passe existant (Face ID/Touch ID propose le remplissage)' },
{ name: 'new-password', usage: 'Nouveau mot de passe (le gestionnaire propose d\'en générer un)' },
{ name: 'one-time-code', usage: 'Code OTP reçu par SMS (auto-lu sur iOS / Android)' },
{ name: 'off', usage: 'Désactive complètement les suggestions' },
];
const ENTER_HINT_CATALOG = [
{ name: 'send', usage: 'Envoyer un message (chat, email)' },
{ name: 'search', usage: 'Rechercher (résultat affiché en bas)' },
{ name: 'go', usage: 'Y aller (URL, action de navigation)' },
{ name: 'done', usage: 'Terminer la saisie et fermer le clavier' },
{ name: 'next', usage: 'Passer au champ suivant du formulaire' },
{ name: 'previous', usage: 'Revenir au champ précédent' },
];
Object.assign(window, { KEYBOARD_CATALOG, AUTOCOMPLETE_CATALOG, ENTER_HINT_CATALOG });
@@ -0,0 +1,286 @@
/* ============================================================
mobile-gestures.jsx
Détecteur de gestes nommés pour smartphone.
Chaque geste a un NOM SYSTÈME, et un composant de TEST.
============================================================ */
const { useState: uG, useRef: rG, useEffect: eG } = React;
/* ============================================================
useGesture — hook bas niveau qui détecte les gestes
Renvoie : { onTouchStart, onTouchMove, onTouchEnd } à attacher
au composant qui doit recevoir les gestes.
Callbacks supportés :
onTap tap simple (< 200ms, ne bouge pas)
onDoubleTap double-tap (deux tap rapides)
onLongPress long press (≥ 500ms sans bouger)
onSwipeLeft swipe vers la gauche
onSwipeRight swipe vers la droite
onSwipeUp swipe vers le haut
onSwipeDown swipe vers le bas
onPanStart début de glisser
onPan cours de glisser ({dx, dy})
onPanEnd fin de glisser
onPinch pincement ({scale, dx, dy})
============================================================ */
function useGesture(handlers = {}) {
const state = rG({
sx: 0, sy: 0, st: 0,
lx: 0, ly: 0, lt: 0,
moved: false, longPressTimer: null,
lastTap: 0, lastTapPos: null,
pinching: false, startDist: 0,
});
const reset = () => {
if (state.current.longPressTimer) clearTimeout(state.current.longPressTimer);
};
const onTouchStart = (e) => {
const t = e.touches[0];
state.current.sx = t.clientX;
state.current.sy = t.clientY;
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.st = Date.now();
state.current.lt = Date.now();
state.current.moved = false;
// Pinch detection
if (e.touches.length === 2) {
const dx = e.touches[1].clientX - t.clientX;
const dy = e.touches[1].clientY - t.clientY;
state.current.startDist = Math.hypot(dx, dy);
state.current.pinching = true;
return;
}
// Long press
if (handlers.onLongPress) {
state.current.longPressTimer = setTimeout(() => {
if (!state.current.moved) {
handlers.onLongPress({ x: t.clientX, y: t.clientY });
state.current.moved = true; // empêche d'autres détections
}
}, 500);
}
handlers.onPanStart && handlers.onPanStart({ x: t.clientX, y: t.clientY });
};
const onTouchMove = (e) => {
const t = e.touches[0];
const dx = t.clientX - state.current.sx;
const dy = t.clientY - state.current.sy;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
state.current.moved = true;
reset();
}
if (state.current.pinching && e.touches.length === 2) {
const px = e.touches[1].clientX - t.clientX;
const py = e.touches[1].clientY - t.clientY;
const dist = Math.hypot(px, py);
const scale = dist / state.current.startDist;
handlers.onPinch && handlers.onPinch({ scale, dx, dy });
return;
}
handlers.onPan && handlers.onPan({ dx, dy, x: t.clientX, y: t.clientY });
state.current.lx = t.clientX;
state.current.ly = t.clientY;
state.current.lt = Date.now();
};
const onTouchEnd = (e) => {
reset();
const dx = state.current.lx - state.current.sx;
const dy = state.current.ly - state.current.sy;
const dt = Date.now() - state.current.st;
handlers.onPanEnd && handlers.onPanEnd({ dx, dy });
if (state.current.pinching) {
state.current.pinching = false;
return;
}
if (state.current.moved && dt < 500) {
const absX = Math.abs(dx), absY = Math.abs(dy);
if (absX > 50 || absY > 50) {
if (absX > absY) {
if (dx > 0) handlers.onSwipeRight && handlers.onSwipeRight({ dx, dt });
else handlers.onSwipeLeft && handlers.onSwipeLeft({ dx, dt });
} else {
if (dy > 0) handlers.onSwipeDown && handlers.onSwipeDown({ dy, dt });
else handlers.onSwipeUp && handlers.onSwipeUp({ dy, dt });
}
}
} else if (!state.current.moved && dt < 200) {
// Tap / DoubleTap
const now = Date.now();
const pos = { x: state.current.lx, y: state.current.ly };
const lp = state.current.lastTapPos;
if (now - state.current.lastTap < 300 && lp && Math.hypot(pos.x - lp.x, pos.y - lp.y) < 30) {
handlers.onDoubleTap && handlers.onDoubleTap(pos);
state.current.lastTap = 0;
} else {
handlers.onTap && handlers.onTap(pos);
state.current.lastTap = now;
state.current.lastTapPos = pos;
}
}
};
return { onTouchStart, onTouchMove, onTouchEnd };
}
/* ============================================================
GestureZone — zone tactile de test
Affiche le dernier geste détecté + un journal des gestes.
Toutes les actions sont nommées explicitement.
============================================================ */
function GestureZone({ label, accept = [] }) {
const [last, setLast] = uG(null);
const [log, setLog] = uG([]);
const [count, setCount] = uG({});
const [trail, setTrail] = uG(null);
const fire = (name, data) => {
setLast({ name, data, time: Date.now() });
setLog((l) => [{ name, t: new Date().toLocaleTimeString('fr-FR', { hour12: false }) }, ...l].slice(0, 5));
setCount((c) => ({ ...c, [name]: (c[name] || 0) + 1 }));
};
const hAll = {
onTap: () => fire('Tap'),
onDoubleTap: () => fire('DoubleTap'),
onLongPress: () => fire('LongPress'),
onSwipeLeft: () => fire('SwipeLeft'),
onSwipeRight: () => fire('SwipeRight'),
onSwipeUp: () => fire('SwipeUp'),
onSwipeDown: () => fire('SwipeDown'),
onPan: ({ dx, dy }) => setTrail({ dx, dy }),
onPanEnd: () => setTrail(null),
onPinch: ({ scale }) => fire('Pinch', { scale: scale.toFixed(2) }),
};
// Filtre uniquement les handlers demandés
const h = accept.length === 0 ? hAll : Object.fromEntries(
Object.entries(hAll).filter(([k]) => accept.some((n) => k.toLowerCase().includes(n.toLowerCase())))
);
const gesture = useGesture(h);
return (
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: 'var(--tile-3d)',
marginBottom: 12,
}}>
{label && (
<div style={{
padding: '10px 14px',
fontFamily: 'var(--font-mono)', fontSize: 11,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--border-1)',
}}>{label}</div>
)}
<div {...gesture}
style={{
height: 200,
position: 'relative',
background: `repeating-linear-gradient(45deg, var(--bg-3) 0 14px, var(--bg-4) 14px 15px)`,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: 'grab',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{/* indicateur central */}
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13,
color: 'var(--ink-3)', textAlign: 'center',
padding: 16, pointerEvents: 'none',
}}>
{last ? (
<div style={{
animation: 'gp-pop .3s cubic-bezier(.3,.7,.3,1.2)',
fontSize: 22, fontWeight: 700, color: 'var(--accent)',
fontFamily: 'var(--font-ui)',
}}>
{last.name}
{last.data && (
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
{Object.entries(last.data).map(([k, v]) => `${k}: ${v}`).join(' · ')}
</div>
)}
</div>
) : (
<span>essaie un geste ici</span>
)}
<style>{`@keyframes gp-pop { from { opacity: 0; transform: scale(.85) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
{/* trail visuel pendant le pan */}
{trail && (
<div style={{
position: 'absolute',
top: '50%', left: '50%',
width: 14, height: 14,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--accent-glow)',
transform: `translate(calc(-50% + ${trail.dx}px), calc(-50% + ${trail.dy}px))`,
pointerEvents: 'none',
}} />
)}
</div>
{/* Journal */}
{log.length > 0 && (
<div style={{
padding: '8px 14px 10px',
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-1)',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--ink-3)',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 9, letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>journal</span>
<span>{log.length} dernier{log.length > 1 ? 's' : ''}</span>
</div>
{log.map((l, i) => (
<div key={i} style={{ opacity: 1 - i * 0.15 }}>
<span style={{ color: 'var(--ink-4)' }}>{l.t}</span>{' '}
<span style={{ color: 'var(--accent)' }}>{l.name}</span>
</div>
))}
</div>
)}
</div>
);
}
/* Catalogue des gestes — utile pour affichage + onglet d'aide */
const GESTURE_CATALOG = [
{ name: 'Tap', icon: 'play', desc: 'Pression rapide sur l\'écran.', usage: 'Action principale (équiv. clic).' },
{ name: 'DoubleTap', icon: 'plus', desc: 'Deux Tap rapprochés (<300 ms).', usage: 'Zoomer une image, liker (style Instagram).' },
{ name: 'LongPress', icon: 'clock', desc: 'Pression maintenue ≥ 500 ms.', usage: 'Ouvrir un menu contextuel, sélectionner.' },
{ name: 'SwipeLeft', icon: 'chevL', desc: 'Glisser le doigt vers la gauche.', usage: 'Naviguer à l\'écran suivant, supprimer une ligne.' },
{ name: 'SwipeRight', icon: 'chevR', desc: 'Glisser le doigt vers la droite.', usage: 'Retour à l\'écran précédent, archiver.' },
{ name: 'SwipeUp', icon: 'chevU', desc: 'Glisser vers le haut.', usage: 'Voir plus de détails, fermer une popup.' },
{ name: 'SwipeDown', icon: 'chevD', desc: 'Glisser vers le bas.', usage: 'Rafraîchir (PullToRefresh), fermer une BottomSheet.' },
{ name: 'Pan', icon: 'grid', desc: 'Glisser en continu (drag).', usage: 'Déplacer un élément, scroll horizontal.' },
{ name: 'Pinch', icon: 'search', desc: 'Écarter / rapprocher 2 doigts.', usage: 'Zoomer une carte, une image.' },
];
Object.assign(window, { useGesture, GestureZone, GESTURE_CATALOG });
@@ -0,0 +1,407 @@
/* ============================================================
mobile-kit.jsx
Composants mobile-first du design system.
Tous nommés explicitement et exposés sur window.
Tactile-ready : hit targets ≥ 44px, animations fluides,
pas de hover, feedback au touch.
============================================================ */
const { useState: uM, useRef: rM, useEffect: eM } = React;
/* ============================================================
StatusBar — barre de statut iOS-like (en haut de l'écran)
Nom système : StatusBar
Usage : décor en haut de toute page mobile.
============================================================ */
function StatusBar({ time = '14:02', battery = 78, signal = 4 }) {
return (
<div style={{
height: 44, flex: '0 0 auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 22px',
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
color: 'var(--ink-1)',
}}>
<span>{time}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* signal bars */}
<span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 1.5 }}>
{[1, 2, 3, 4].map((b) => (
<span key={b} style={{
width: 3, height: 3 + b * 2, borderRadius: 1,
background: b <= signal ? 'var(--ink-1)' : 'var(--ink-4)',
}} />
))}
</span>
<Icon name="network" size={13} />
{/* battery */}
<span style={{
width: 24, height: 11, borderRadius: 3,
border: '1px solid var(--ink-1)',
position: 'relative', marginLeft: 2,
}}>
<span style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${battery / 100})`,
background: battery < 20 ? 'var(--err)' : 'var(--ink-1)',
borderRadius: 1,
}} />
<span style={{
position: 'absolute', right: -3, top: 3, bottom: 3,
width: 2, background: 'var(--ink-1)',
borderRadius: '0 1px 1px 0',
}} />
</span>
</span>
</div>
);
}
/* ============================================================
NavBar — barre de navigation en haut (titre + actions)
Nom système : NavBar
Usage : titre d'écran avec retour optionnel à gauche, actions à droite.
============================================================ */
function NavBar({ title, subtitle, onBack, right, large }) {
return (
<div style={{
flex: '0 0 auto',
padding: large ? '8px 16px 16px' : '8px 12px',
display: 'flex', flexDirection: 'column', gap: 4,
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderBottom: '1px solid var(--border-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', minHeight: 36, gap: 8 }}>
{onBack && (
<button onClick={onBack} style={{
width: 36, height: 36, borderRadius: 8,
background: 'transparent', border: 'none',
color: 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>
<Icon name="chevL" size={20} />
</button>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{!large && (
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', textAlign: onBack ? 'center' : 'left' }}>
{title}
</div>
)}
</div>
{right && <div style={{ display: 'flex', gap: 4 }}>{right}</div>}
</div>
{large && (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 32, fontWeight: 700, lineHeight: 1.1 }}>{title}</div>
{subtitle && <div style={{ fontSize: 13, color: 'var(--ink-3)', marginTop: 4 }}>{subtitle}</div>}
</div>
)}
</div>
);
}
/* ============================================================
TabBar — barre d'onglets en bas (iOS/Android)
Nom système : TabBar
Usage : navigation principale entre 3-5 sections de l'app.
============================================================ */
function TabBar({ items, active, onSelect }) {
return (
<div style={{
flex: '0 0 auto',
display: 'flex', justifyContent: 'space-around', alignItems: 'stretch',
padding: '6px 8px 18px',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px) saturate(150%)',
borderTop: '1px solid var(--border-2)',
}}>
{items.map((it) => {
const isActive = active === it.id;
return (
<button key={it.id} onClick={() => onSelect(it.id)} style={{
flex: 1, minHeight: 50,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 3, padding: 0,
background: 'transparent', border: 'none',
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
cursor: 'pointer',
transition: 'color .2s, transform .12s',
transform: isActive ? 'translateY(-1px)' : 'translateY(0)',
}}>
<Icon name={it.icon} size={22} />
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.04em', textTransform: 'uppercase',
fontWeight: isActive ? 700 : 500,
}}>{it.label}</span>
</button>
);
})}
</div>
);
}
/* ============================================================
ListRow — ligne d'une liste réglages (style iOS)
Nom système : ListRow
Usage : option dans une liste de réglages. ≥ 44px de hauteur.
============================================================ */
function ListRow({ icon, iconColor, label, value, right, onClick, danger }) {
const isInteractive = !!onClick;
const Tag = isInteractive ? 'button' : 'div';
return (
<Tag onClick={onClick} style={{
width: '100%',
minHeight: 52,
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: 'transparent',
border: 'none', borderBottom: '1px solid var(--border-1)',
color: danger ? 'var(--err)' : 'var(--ink-1)',
cursor: isInteractive ? 'pointer' : 'default',
textAlign: 'left',
transition: 'background .12s',
WebkitTapHighlightColor: 'transparent',
}}
onTouchStart={isInteractive ? (e) => e.currentTarget.style.background = 'var(--bg-3)' : undefined}
onTouchEnd={isInteractive ? (e) => e.currentTarget.style.background = 'transparent' : undefined}>
{icon && (
<span style={{
width: 30, height: 30, borderRadius: 7,
background: iconColor || 'var(--bg-4)',
color: iconColor ? 'var(--bg-1)' : 'var(--ink-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
flex: '0 0 auto',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
}}>
<Icon name={icon} size={15} />
</span>
)}
<span style={{ flex: 1, fontSize: 15, fontWeight: 500 }}>{label}</span>
{value && <span style={{ fontSize: 14, color: 'var(--ink-3)' }}>{value}</span>}
{right === undefined && onClick && <Icon name="chevR" size={14} style={{ color: 'var(--ink-3)' }} />}
{right}
</Tag>
);
}
/* ============================================================
ListSection — groupe de ListRow avec titre
Nom système : ListSection
============================================================ */
function ListSection({ title, hint, children }) {
return (
<div style={{ marginBottom: 18 }}>
{title && (
<div style={{
padding: '0 16px 6px',
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: 'var(--ink-3)',
}}>{title}</div>
)}
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
margin: '0 12px',
overflow: 'hidden',
boxShadow: 'var(--shadow-1)',
}}>{children}</div>
{hint && (
<div style={{
padding: '6px 16px 0', fontSize: 12, color: 'var(--ink-3)',
lineHeight: 1.4,
}}>{hint}</div>
)}
</div>
);
}
/* ============================================================
ActionCard — grosse carte d'action tactile
Nom système : ActionCard
Usage : actions principales sur écran d'accueil.
============================================================ */
function ActionCard({ icon, iconColor, title, subtitle, value, onClick, badge }) {
return (
<button onClick={onClick} className="touch-press" style={{
flex: 1, minWidth: 0, minHeight: 110,
padding: 14,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--ink-1)',
textAlign: 'left',
display: 'flex', flexDirection: 'column', gap: 6,
cursor: 'pointer',
boxShadow: 'var(--tile-3d)',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
<span style={{
width: 38, height: 38, borderRadius: 9,
background: `linear-gradient(135deg, ${iconColor || 'var(--accent)'}, color-mix(in oklch, ${iconColor || 'var(--accent)'} 60%, black))`,
color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
}}>
<Icon name={icon} size={18} />
</span>
<span style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.2 }}>{title}</span>
{subtitle && <span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{subtitle}</span>}
{value && (
<span className="mono" style={{ fontSize: 22, fontWeight: 700, marginTop: 'auto' }}>{value}</span>
)}
{badge && (
<span style={{
position: 'absolute', top: 10, right: 10,
minWidth: 18, height: 18, borderRadius: 9,
padding: '0 6px',
background: 'var(--err)', color: 'var(--bg-1)',
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>{badge}</span>
)}
</button>
);
}
/* ============================================================
PrimaryButton — gros bouton plein largeur tactile
Nom système : PrimaryButton
Usage : action principale d'un écran (sauvegarder, valider).
============================================================ */
function PrimaryButton({ children, icon, onClick, variant = 'primary', size = 'lg' }) {
const sizes = {
md: { h: 44, fontSize: 14 },
lg: { h: 52, fontSize: 16 },
}[size];
const styles = {
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
danger: { bg: 'var(--err)', fg: '#fff', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="touch-press" style={{
width: '100%',
height: sizes.h,
background: styles.bg,
color: styles.fg,
border: `1px solid ${styles.bd}`,
borderRadius: 12,
fontFamily: 'var(--font-ui)', fontSize: sizes.fontSize, fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
boxShadow: variant === 'primary' ? '0 4px 12px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.2)' : 'var(--shadow-1)',
WebkitTapHighlightColor: 'transparent',
}}>
{icon && <Icon name={icon} size={18} />}
{children}
</button>
);
}
/* ============================================================
SegmentedControl — sélecteur segmenté iOS-style
Nom système : SegmentedControl
Usage : 2-4 options exclusives, jamais plus.
============================================================ */
function SegmentedControl({ value, onChange, options }) {
return (
<div style={{
display: 'flex',
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
borderRadius: 9,
padding: 3,
gap: 2,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'string' ? null : o.icon;
const active = value === v;
return (
<button key={v} onClick={() => onChange(v)} style={{
flex: 1, minHeight: 36,
padding: '6px 10px',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: 'none', borderRadius: 6,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
transition: 'background .18s, color .18s, transform .12s',
transform: active ? 'translateY(-0.5px)' : 'translateY(0)',
boxShadow: active ? '0 2px 5px var(--accent-glow)' : 'none',
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={13} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
SearchBar — champ de recherche mobile
Nom système : SearchBar
============================================================ */
function SearchBar({ value, onChange, placeholder = 'Rechercher' }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 10,
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.25)',
}}>
<Icon name="search" size={15} style={{ color: 'var(--ink-3)' }} />
<input type="text" value={value} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15,
}} />
{value && (
<button onClick={() => onChange('')} style={{
width: 22, height: 22, borderRadius: '50%',
border: 'none', background: 'var(--ink-4)', color: 'var(--bg-1)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}><Icon name="close" size={10} /></button>
)}
</div>
);
}
Object.assign(window, {
StatusBar, NavBar, TabBar, ListRow, ListSection,
ActionCard, PrimaryButton, SegmentedControl, SearchBar,
});
/* Effets tactiles : pression au touch (pas de hover) */
(function injectMobileFX() {
if (document.getElementById('mobile-fx')) return;
const s = document.createElement('style');
s.id = 'mobile-fx';
s.textContent = `
.touch-press {
transition: transform .08s ease-out, filter .08s, box-shadow .08s;
}
.touch-press:active {
transform: scale(0.97);
filter: brightness(0.92);
}
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,390 @@
/* ============================================================
mobile-sheets.jsx
Types de fenêtres mobiles + composants spécifiques.
Chaque type a un nom système ET un cas d'usage préconisé.
============================================================ */
const { useState: uS, useRef: rS, useEffect: eS } = React;
/* ============================================================
BottomSheet — feuille modale qui monte du bas
Nom système : BottomSheet
Cas d'usage : action contextuelle, formulaire court, choix
dans une liste. À privilégier sur mobile à la
place d'une popup centrée (plus accessible au pouce).
Gestes : swipe down pour fermer.
============================================================ */
function BottomSheet({ open, onClose, title, children, footer, height = 'auto' }) {
const [dragY, setDragY] = uS(0);
const [closing, setClosing] = uS(false);
const startY = rS(0);
eS(() => {
if (open) { setDragY(0); setClosing(false); }
}, [open]);
if (!open && !closing) return null;
const onStart = (e) => {
startY.current = (e.touches ? e.touches[0].clientY : e.clientY);
};
const onMove = (e) => {
const y = (e.touches ? e.touches[0].clientY : e.clientY);
const d = Math.max(0, y - startY.current);
setDragY(d);
};
const onEnd = () => {
if (dragY > 80) {
setClosing(true);
setTimeout(() => { setClosing(false); setDragY(0); onClose(); }, 200);
} else {
setDragY(0);
}
};
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: `rgba(0,0,0,${closing ? 0 : 0.5 * (1 - dragY / 400)})`,
transition: 'background .2s',
display: 'flex', alignItems: 'flex-end',
}}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
maxHeight: '85%',
height: height === 'auto' ? 'auto' : height,
background: 'var(--bg-2)',
borderTop: '1px solid var(--border-2)',
borderRadius: '20px 20px 0 0',
boxShadow: '0 -8px 32px rgba(0,0,0,0.5)',
transform: `translateY(${closing ? '100%' : dragY + 'px'})`,
transition: closing ? 'transform .2s ease-in' : (dragY === 0 ? 'transform .3s cubic-bezier(.3,.7,.3,1.2)' : 'none'),
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle */}
<div onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart}
style={{
padding: '10px 0 6px',
display: 'flex', justifyContent: 'center',
cursor: 'grab', touchAction: 'none',
}}>
<div style={{
width: 36, height: 5, borderRadius: 3,
background: 'var(--ink-4)',
}}/>
</div>
{title && (
<div style={{
padding: '0 18px 12px',
display: 'flex', alignItems: 'center', gap: 8,
borderBottom: '1px solid var(--border-1)',
}}>
<div style={{ flex: 1, fontSize: 17, fontWeight: 700 }}>{title}</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: '50%',
background: 'var(--bg-4)', border: 'none', color: 'var(--ink-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
WebkitTapHighlightColor: 'transparent',
}}><Icon name="close" size={12} /></button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px 22px',
borderTop: '1px solid var(--border-1)',
display: 'flex', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
ActionSheet — menu d'actions style iOS
Nom système : ActionSheet
Cas d'usage : choix parmi 2-6 actions sur un élément
(équivalent menu contextuel desktop).
============================================================ */
function ActionSheet({ open, onClose, title, actions, cancelLabel = 'Annuler' }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end',
padding: 10,
animation: 'as-fade .2s',
}}>
<style>{`
@keyframes as-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes as-slide { from { transform: translateY(100%) } to { transform: translateY(0) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
width: '100%',
display: 'flex', flexDirection: 'column', gap: 8,
animation: 'as-slide .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<div style={{
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
}}>
{title && (
<div style={{
padding: '12px 16px',
fontSize: 12, color: 'var(--ink-3)',
textAlign: 'center',
borderBottom: '1px solid var(--border-1)',
}}>{title}</div>
)}
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
width: '100%', minHeight: 52,
background: 'transparent', border: 'none',
borderTop: i === 0 || (title && i === 0) ? 'none' : '1px solid var(--border-1)',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={16} />}
{a.label}
</button>
))}
</div>
<button onClick={onClose} className="touch-press" style={{
width: '100%', minHeight: 52,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
color: 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 16, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'var(--shadow-2)',
}}>{cancelLabel}</button>
</div>
</div>
);
}
/* ============================================================
AlertDialog — alerte modale centrée
Nom système : AlertDialog
Cas d'usage : message critique, demande de confirmation
ferme (suppression, déconnexion).
============================================================ */
function AlertDialog({ open, onClose, icon, iconColor, title, message, actions }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
animation: 'as-fade .2s',
}}>
<div style={{
width: '100%', maxWidth: 320,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-3)',
animation: 'pop-in .25s cubic-bezier(.3,.7,.3,1.2)',
}}>
<style>{`@keyframes pop-in { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: scale(1) } }`}</style>
<div style={{
padding: '22px 22px 18px',
textAlign: 'center',
}}>
{icon && (
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: `color-mix(in oklch, ${iconColor || 'var(--accent)'} 18%, transparent)`,
color: iconColor || 'var(--accent)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}>
<Icon name={icon} size={24} />
</div>
)}
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--ink-1)', marginBottom: 6 }}>{title}</div>
{message && <div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.4 }}>{message}</div>}
</div>
<div style={{
display: 'flex',
borderTop: '1px solid var(--border-1)',
}}>
{actions.map((a, i) => (
<button key={i} onClick={() => { onClose(); a.onClick && a.onClick(); }}
className="touch-press"
style={{
flex: 1, minHeight: 46,
background: 'transparent', border: 'none',
borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
color: a.danger ? 'var(--err)' : 'var(--accent)',
fontFamily: 'var(--font-ui)', fontSize: 15,
fontWeight: a.primary ? 700 : 500,
cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>{a.label}</button>
))}
</div>
</div>
</div>
);
}
/* ============================================================
Toast — notification éphémère en haut
Nom système : Toast
Cas d'usage : feedback succès/erreur après une action.
Disparaît automatiquement après 2.5s.
============================================================ */
function Toast({ open, onClose, icon, message, variant = 'ok', duration = 2500 }) {
eS(() => {
if (open) {
const t = setTimeout(onClose, duration);
return () => clearTimeout(t);
}
}, [open, duration, onClose]);
if (!open) return null;
const colors = {
ok: { bg: 'var(--ok)', fg: 'var(--bg-1)', icon: 'play' },
warn: { bg: 'var(--warn)', fg: 'var(--bg-1)', icon: 'alert' },
err: { bg: 'var(--err)', fg: '#fff', icon: 'close' },
info: { bg: 'var(--info)', fg: 'var(--bg-1)', icon: 'bell' },
}[variant];
return (
<div style={{
position: 'absolute', top: 50, left: 16, right: 16, zIndex: 300,
padding: '12px 16px',
background: colors.bg,
color: colors.fg,
borderRadius: 12,
display: 'flex', alignItems: 'center', gap: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
animation: 'toast-in .3s cubic-bezier(.3,.7,.3,1.2)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>
<style>{`@keyframes toast-in { from { opacity: 0; transform: translateY(-12px) } to { opacity: 1; transform: translateY(0) } }`}</style>
<Icon name={icon || colors.icon} size={18} />
<span style={{ flex: 1 }}>{message}</span>
</div>
);
}
/* ============================================================
FAB — Floating Action Button (Android Material)
Nom système : FAB
Cas d'usage : action principale unique sur un écran
(créer, ajouter). Toujours en bas à droite.
============================================================ */
function FAB({ icon, label, onClick }) {
return (
<button onClick={onClick} className="touch-press" style={{
position: 'absolute', bottom: 90, right: 18,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)',
color: 'var(--bg-1)',
border: 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 6px 18px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.4)',
zIndex: 50,
WebkitTapHighlightColor: 'transparent',
}} aria-label={label}>
<Icon name={icon} size={22} />
</button>
);
}
/* ============================================================
PullToRefresh — wrapper pour rafraîchir au pull-down
Nom système : PullToRefresh
Geste associé : swipe down depuis le haut du contenu.
============================================================ */
function PullToRefresh({ onRefresh, children }) {
const [pull, setPull] = uS(0);
const [refreshing, setRefreshing] = uS(false);
const startY = rS(0);
const wrap = rS();
const onStart = (e) => {
if (wrap.current && wrap.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
} else {
startY.current = null;
}
};
const onMove = (e) => {
if (startY.current == null) return;
const d = e.touches[0].clientY - startY.current;
if (d > 0) setPull(Math.min(d, 100));
};
const onEnd = async () => {
if (pull > 60 && !refreshing) {
setRefreshing(true);
setPull(60);
try { await Promise.resolve(onRefresh && onRefresh()); }
finally {
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setPull(0);
}
} else {
setPull(0);
}
startY.current = null;
};
return (
<div ref={wrap}
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
style={{ position: 'relative', overflowY: 'auto', height: '100%', WebkitOverflowScrolling: 'touch' }}>
{/* indicateur */}
<div style={{
position: 'absolute', top: -20 + pull, left: 0, right: 0,
display: 'flex', justifyContent: 'center',
transition: pull === 0 || pull === 60 ? 'top .2s ease-out' : 'none',
pointerEvents: 'none',
zIndex: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)',
boxShadow: 'var(--shadow-2)',
}}>
<Icon name="refresh" size={14} style={{
transform: `rotate(${pull * 4}deg)`,
animation: refreshing ? 'spin 1s linear infinite' : 'none',
transition: refreshing ? 'none' : 'transform .1s linear',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
<div style={{
transform: `translateY(${pull}px)`,
transition: pull === 0 || pull === 60 ? 'transform .2s ease-out' : 'none',
}}>{children}</div>
</div>
);
}
Object.assign(window, {
BottomSheet, ActionSheet, AlertDialog, Toast, FAB, PullToRefresh,
});
@@ -0,0 +1,137 @@
/* ============================================================
mobile-swipeable.jsx
SwipeableRow — ligne qui révèle des actions au swipe.
============================================================ */
const { useState: uSw, useRef: rSw, useEffect: eSw } = React;
/* ============================================================
SwipeableRow
Nom système : SwipeableRow
Cas d'usage : ligne d'une liste avec actions cachées
(archive, suppression, marquer comme lu…).
Style iOS Mail / Things / Apple Reminders.
Gestes : SwipeLeft (révèle leftActions à droite),
SwipeRight (révèle rightActions à gauche),
Tap sur la ligne (action principale),
Tap sur une action (déclenche l'action puis ferme).
============================================================ */
function SwipeableRow({ children, leftActions = [], rightActions = [], onTap }) {
// leftActions s'affichent quand on swipe vers la GAUCHE
// (la ligne se décale à gauche, dévoilant les actions à DROITE)
const [tx, setTx] = uSw(0);
const [dragging, setDragging] = uSw(false);
const startX = rSw(0);
const initialTx = rSw(0);
const leftW = leftActions.length * 76; // actions à droite (révélées par swipe gauche)
const rightW = rightActions.length * 76; // actions à gauche (révélées par swipe droit)
const snap = (x) => {
if (x < -leftW * 0.5) setTx(-leftW);
else if (x > rightW * 0.5) setTx(rightW);
else setTx(0);
};
const onStart = (e) => {
setDragging(true);
startX.current = (e.touches ? e.touches[0].clientX : e.clientX);
initialTx.current = tx;
};
const onMove = (e) => {
if (!dragging) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
let d = initialTx.current + (x - startX.current);
// limite + élasticité hors zone
if (d > rightW) d = rightW + (d - rightW) * 0.3;
if (d < -leftW) d = -leftW + (d + leftW) * 0.3;
setTx(d);
};
const onEnd = () => {
setDragging(false);
snap(tx);
};
const fire = (action) => {
setTx(0);
setTimeout(() => action.onClick && action.onClick(), 200);
};
const handleTap = (e) => {
if (tx !== 0) { setTx(0); return; }
if (Math.abs(tx) < 4 && onTap) onTap(e);
};
return (
<div style={{
position: 'relative',
overflow: 'hidden',
background: 'var(--bg-3)',
WebkitUserSelect: 'none', userSelect: 'none',
}}>
{/* Actions à GAUCHE (révélées par swipe droit) */}
{rightActions.length > 0 && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: rightW,
}}>
{rightActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--info)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Actions à DROITE (révélées par swipe gauche) */}
{leftActions.length > 0 && (
<div style={{
position: 'absolute', right: 0, top: 0, bottom: 0,
display: 'flex', alignItems: 'stretch',
width: leftW,
}}>
{leftActions.map((a, i) => (
<button key={i} onClick={() => fire(a)} className="touch-press" style={{
width: 76,
background: a.color || 'var(--err)',
color: a.fg || '#fff',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{a.icon && <Icon name={a.icon} size={20} />}
{a.label}
</button>
))}
</div>
)}
{/* Ligne déplaçable */}
<div
onTouchStart={onStart} onTouchMove={onMove} onTouchEnd={onEnd}
onMouseDown={onStart} onMouseMove={onMove} onMouseUp={onEnd} onMouseLeave={onEnd}
onClick={handleTap}
style={{
position: 'relative',
background: 'var(--bg-3)',
transform: `translateX(${tx}px)`,
transition: dragging ? 'none' : 'transform .25s cubic-bezier(.3,.7,.3,1.1)',
cursor: dragging ? 'grabbing' : (onTap ? 'pointer' : 'default'),
touchAction: 'pan-y',
}}>
{children}
</div>
</div>
);
}
Object.assign(window, { SwipeableRow });
@@ -0,0 +1,656 @@
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes — Font Awesome 6 Free.
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
dans le <head>. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
fontSize: size,
width: size,
height: size,
lineHeight: `${size}px`,
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
color: 'currentColor',
...style,
}} />
);
};
/* ============================================================
Tooltip — apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={onEnter} onMouseLeave={onLeave}>
{children}
{show && (
<span className="glass-strong" style={{
position: 'absolute', ...sides[side],
padding: '6px 10px',
borderRadius: 6,
fontSize: 12, lineHeight: 1.3,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
zIndex: 1000,
pointerEvents: 'none',
fontFamily: 'JetBrains Mono, monospace',
letterSpacing: '0.02em',
}}>{label}</span>
)}
</span>
);
}
/* ============================================================
IconButton — bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
<Tooltip label={label}>
<button onClick={onClick} className="interactive" style={{
width: size, height: size,
background: bg,
color: fg,
border: `1px solid ${bd}`,
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0, cursor: 'pointer',
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
}}>
<Icon name={icon} size={Math.round(size * 0.5)} />
</button>
</Tooltip>
);
}
/* ============================================================
Toggle on/off — switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
<button onClick={() => onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
<span style={{
position: 'absolute', top: 1, left: on ? 21 : 1,
width: 18, height: 18, borderRadius: '50%',
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
boxShadow: 'var(--shadow-1)',
}} />
</button>
</div>
);
}
/* ============================================================
Status LED — pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
)}
<span style={{
display: 'inline-block',
width: size, height: size,
borderRadius: '50%',
background: c,
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
flex: '0 0 auto',
}} />
</>
);
}
/* ============================================================
BatteryGauge — jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
<div className="bg-hover" style={{
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
'--bg-glow': glowVar,
}}>
{(icon || label) && (
<span style={{
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
minWidth: 90,
}}>
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
</span>
)}
<div className="bg-bar" style={{
flex: 1, height: 12, borderRadius: 3,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden', position: 'relative',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 2,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
</div>
<span className="mono" style={{
flex: '0 0 auto', fontSize: 13,
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
}}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
);
}
return (
<div className="bg-hover" style={{
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
'--bg-glow': glowVar,
}}>
{label && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="label">{label}</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
)}
<div className="bg-bar" style={{
position: 'relative',
height, borderRadius: 4,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 3,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
<div style={{
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
borderRadius: '3px 3px 0 0',
pointerEvents: 'none',
}} />
</div>
</div>
);
}
/* ============================================================
RadialGauge — jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
<defs>
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
</defs>
{/* arc background */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter={`url(#glow-${label})`} opacity="0.7" />
{/* arc value crisp */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
</svg>
<div style={{ marginTop: -10, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
</div>
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
</div>
</div>
);
}
/* ============================================================
BigRadialGauge — la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
<svg width={size} height={size * 0.85}>
<defs>
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
<stop offset="1" stopColor={color}/>
</linearGradient>
</defs>
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return <line key={i}
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
/>;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return <text key={v} x={x} y={y} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="11"
fill="var(--ink-3)">{v}</text>;
})}
{/* arc bg */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter="url(#biggauge-glow)" opacity="0.55" />
{/* arc value */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
{/* needle */}
<line x1={cx} y1={cy}
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
</svg>
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
<div className="mono" style={{
fontSize: 64, fontWeight: 700, lineHeight: 1,
color: 'var(--ink-1)',
textShadow: `0 0 20px ${color}33`,
}}>{value}</div>
<div className="label" style={{ marginTop: 6 }}>{label}</div>
</div>
</div>
);
}
/* ============================================================
Popup — modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadein .2s ease-out',
}} onClick={onClose}>
<style>{`
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-1)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-3)',
}}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
</div>
<div style={{ padding: 18 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-1)',
background: 'var(--bg-2)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
Button — bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="interactive" style={{
height: sizes.h,
padding: sizes.padding,
background: variants.bg,
color: variants.fg,
border: `1px solid ${variants.bd}`,
borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
cursor: 'pointer',
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
}
/* ============================================================
TreeNav — arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<div key={g.id}>
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
<Icon name="chevR" size={12} style={{
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform .15s',
color: 'var(--ink-3)',
}} />
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
{g.count != null && (
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{g.count}
</span>
)}
</div>
{open[g.id] && (
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
{g.children.map(c => {
const active = c.id === activeId;
return (
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
<path d={area} fill={color} opacity="0.12" />
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ============================================================
LineChart — grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
<g key={p}>
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
<text x={padding.l - 6} y={y + 3} textAnchor="end"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
</g>
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
<g key={si}>
<path d={area} fill={s.color} opacity="0.12" />
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
</svg>
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();
@@ -0,0 +1,204 @@
/* ============================================================
ui-tokens.css
Design tokens Gruvbox Seventies — dark (par défaut) + light.
Sombre délavé (pas noir intense) / gris clair usé (pas blanc pur).
============================================================ */
:root,
[data-theme="dark"] {
/* Couches de fond — sombre délavé, brun-gris chaud */
--bg-0: #221c17; /* niveau le plus profond (rare) */
--bg-1: #2a231d; /* fond app */
--bg-2: #322a23; /* panneaux */
--bg-3: #3c332a; /* cartes */
--bg-4: #4a4035; /* hover */
--bg-5: #5a4f43; /* press / actif */
/* Surfaces translucides */
--surf-glass: rgba(50, 42, 35, 0.72);
--surf-glass-strong: rgba(50, 42, 35, 0.92);
--surf-glass-soft: rgba(50, 42, 35, 0.42);
/* Bordures */
--border-1: rgba(168, 153, 132, 0.18);
--border-2: rgba(168, 153, 132, 0.32);
--border-3: rgba(168, 153, 132, 0.55);
/* Texte */
--ink-1: #f2e5c7; /* cream principal */
--ink-2: #d5c4a1; /* secondaire */
--ink-3: #a89984; /* labels / hints */
--ink-4: #7c6f64; /* désactivé */
/* Accent orange seventies */
--accent: #fe8019;
--accent-soft: #d65d0e;
--accent-glow: rgba(254, 128, 25, 0.35);
--accent-tint: rgba(254, 128, 25, 0.12);
/* Statuts */
--ok: #4dbb26;
--ok-glow: rgba(77, 187, 38, 0.45);
--warn: #fabd2f;
--warn-glow: rgba(250, 189, 47, 0.45);
--err: #fb4934;
--err-glow: rgba(251, 73, 52, 0.4);
--info: #83a598;
--info-glow: rgba(131, 165, 152, 0.4);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #3db0d1;
--blue-glow: rgba(61, 176, 209, 0.45);
--purple: #c882c8;
--purple-glow: rgba(200, 130, 200, 0.45);
/* Ombres */
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
--shadow-2: 0 4px 12px rgba(0,0,0,0.45);
--shadow-3: 0 12px 32px rgba(0,0,0,0.55);
--shadow-press: inset 0 2px 4px rgba(0,0,0,0.5);
/* Relief 3D pour tuiles : highlight haut + ombre bas + ombre portée */
--tile-3d:
inset 0 1px 0 rgba(255, 230, 180, 0.12),
inset 0 -1px 0 rgba(0, 0, 0, 0.45),
0 1px 0 rgba(0, 0, 0, 0.35),
0 2px 4px rgba(0, 0, 0, 0.4),
0 8px 18px rgba(0, 0, 0, 0.5);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 230, 180, 0.18),
inset 0 -2px 0 rgba(0, 0, 0, 0.55),
0 1px 0 rgba(0, 0, 0, 0.4),
0 4px 8px rgba(0, 0, 0, 0.5),
0 14px 28px rgba(0, 0, 0, 0.55);
/* Polices */
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-terminal: 'Share Tech Mono', 'VT323', 'Courier New', monospace;
}
[data-theme="light"] {
/* Gris clair usé, légèrement chaud (pas blanc pur) */
--bg-0: #b8b2a3;
--bg-1: #d5d0c5;
--bg-2: #dcd7cc;
--bg-3: #e3ded3;
--bg-4: #ccc6b8;
--bg-5: #bdb6a7;
--surf-glass: rgba(220, 215, 204, 0.72);
--surf-glass-strong: rgba(220, 215, 204, 0.94);
--surf-glass-soft: rgba(220, 215, 204, 0.42);
--border-1: rgba(60, 56, 54, 0.15);
--border-2: rgba(60, 56, 54, 0.28);
--border-3: rgba(60, 56, 54, 0.5);
--ink-1: #28241f;
--ink-2: #3c3836;
--ink-3: #5a544c;
--ink-4: #8a8278;
--accent: #af3a03;
--accent-soft: #d65d0e;
--accent-glow: rgba(175, 58, 3, 0.28);
--accent-tint: rgba(175, 58, 3, 0.08);
--ok: #3c911c;
--ok-glow: rgba(60, 145, 28, 0.32);
--warn: #b57614;
--warn-glow: rgba(181, 118, 20, 0.35);
--err: #9d0006;
--err-glow: rgba(157, 0, 6, 0.3);
--info: #427b58;
--info-glow: rgba(66, 123, 88, 0.3);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #2d82a3;
--blue-glow: rgba(45, 130, 163, 0.32);
--purple: #8c468c;
--purple-glow: rgba(140, 70, 140, 0.32);
--shadow-1: 0 1px 2px rgba(40,30,20,0.12);
--shadow-2: 0 4px 12px rgba(40,30,20,0.15);
--shadow-3: 0 12px 32px rgba(40,30,20,0.2);
--shadow-press: inset 0 2px 4px rgba(40,30,20,0.18);
/* Relief light : highlight haut blanc cassé + ombre marquée */
--tile-3d:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 0 rgba(60, 50, 40, 0.18),
0 1px 0 rgba(60, 50, 40, 0.1),
0 2px 4px rgba(60, 50, 40, 0.12),
0 8px 18px rgba(60, 50, 40, 0.18);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -2px 0 rgba(60, 50, 40, 0.22),
0 1px 0 rgba(60, 50, 40, 0.15),
0 4px 8px rgba(60, 50, 40, 0.18),
0 14px 28px rgba(60, 50, 40, 0.22);
}
/* ============================================================
Reset minimal + base typo
============================================================ */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-ui);
font-size: 14px;
color: var(--ink-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--font-mono); }
.terminal { font-family: var(--font-terminal); letter-spacing: 0.02em; }
.label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
}
/* ============================================================
Surfaces — relief 3D marqué, AUCUN effet hover
============================================================ */
.glass {
background: var(--surf-glass);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid var(--border-2);
box-shadow: var(--tile-3d);
}
.glass-strong {
background: var(--surf-glass-strong);
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
border: 1px solid var(--border-3);
box-shadow: var(--tile-3d-strong);
}
/* Élément cliquable : pas de hover, mais réelle pression 3D au clic */
.interactive {
cursor: pointer;
transition: transform .04s ease-out, box-shadow .04s, background .04s;
transform: translateY(0);
}
.interactive:active {
transform: translateY(1px);
box-shadow: var(--shadow-press) !important;
filter: brightness(0.92);
}
/* Scrollbar custom */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: var(--accent-soft); }
@@ -0,0 +1,378 @@
/* ============================================================
tokens.gnome.css — Tokens pour applications GNOME (GTK 4 / libadwaita)
Gruvbox seventies · v1.0
============================================================
Usage dans une app GTK 4 / libadwaita :
#include <gtk/gtk.h>
GtkCssProvider *provider = gtk_css_provider_new();
gtk_css_provider_load_from_path(provider, "tokens.gnome.css");
gtk_style_context_add_provider_for_display(
gdk_display_get_default(), GTK_STYLE_PROVIDER(provider),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
Python (PyGObject) :
css_provider = Gtk.CssProvider()
css_provider.load_from_path("tokens.gnome.css")
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
GJS :
const provider = new Gtk.CssProvider();
provider.load_from_path('tokens.gnome.css');
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
============================================================ */
/* ============================================================
THÈME SOMBRE (défaut)
============================================================ */
/* Couches de fond (du plus profond au plus haut) */
@define-color bg_0 #221c17;
@define-color bg_1 #2a231d;
@define-color bg_2 #322a23;
@define-color bg_3 #3c332a;
@define-color bg_4 #4a4035;
@define-color bg_5 #5a4f43;
/* Encres / texte */
@define-color ink_1 #f2e5c7;
@define-color ink_2 #d5c4a1;
@define-color ink_3 #a89984;
@define-color ink_4 #7c6f64;
/* Accent orange seventies */
@define-color accent_color #fe8019;
@define-color accent_soft #d65d0e;
@define-color accent_fg_color #221c17;
/* Statuts */
@define-color success_color #4dbb26;
@define-color warning_color #fabd2f;
@define-color error_color #fb4934;
@define-color info_color #83a598;
@define-color blue_color #3db0d1;
@define-color purple_color #c882c8;
/* Bordures */
@define-color border_1 alpha(#a89984, 0.18);
@define-color border_2 alpha(#a89984, 0.32);
@define-color border_3 alpha(#a89984, 0.55);
/* Couleurs sémantiques GNOME / libadwaita (overrides) */
@define-color window_bg_color @bg_1;
@define-color window_fg_color @ink_1;
@define-color view_bg_color @bg_2;
@define-color view_fg_color @ink_1;
@define-color headerbar_bg_color @bg_2;
@define-color headerbar_fg_color @ink_1;
@define-color headerbar_border_color @border_2;
@define-color headerbar_backdrop_color @bg_1;
@define-color sidebar_bg_color @bg_2;
@define-color sidebar_fg_color @ink_1;
@define-color sidebar_backdrop_color @bg_1;
@define-color popover_bg_color @bg_3;
@define-color popover_fg_color @ink_1;
@define-color card_bg_color @bg_3;
@define-color card_fg_color @ink_1;
@define-color shade_color alpha(black, 0.4);
@define-color scrollbar_outline_color alpha(@ink_3, 0.3);
/* ============================================================
COMPOSANTS GTK — habillage Gruvbox seventies
============================================================ */
/* Fond global */
window {
background-color: @window_bg_color;
color: @window_fg_color;
font-family: 'Inter', 'Cantarell', sans-serif;
font-size: 14px;
}
/* HeaderBar (barre de titre) */
headerbar {
background: @bg_2;
color: @ink_1;
border-bottom: 1px solid @border_2;
box-shadow: inset 0 1px 0 alpha(white, 0.04);
min-height: 48px;
}
headerbar .title {
font-weight: 700;
font-size: 15px;
}
headerbar .subtitle {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: @ink_3;
}
/* Boutons — relief 3D et accent */
button {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 8px;
padding: 6px 12px;
font-weight: 500;
box-shadow:
inset 0 1px 0 alpha(white, 0.06),
inset 0 -1px 0 alpha(black, 0.3),
0 1px 2px alpha(black, 0.4);
transition: all 60ms ease;
}
button:active {
background: @bg_4;
box-shadow: inset 0 2px 4px alpha(black, 0.5);
transform: translateY(1px);
}
button:disabled {
color: @ink_4;
opacity: 0.6;
}
/* Bouton "suggested-action" = primary (accent orange) */
button.suggested-action {
background: @accent_color;
color: @accent_fg_color;
border-color: @accent_soft;
box-shadow:
inset 0 1px 0 alpha(white, 0.2),
0 2px 6px alpha(@accent_color, 0.35);
}
button.suggested-action:active {
background: @accent_soft;
}
/* Bouton "destructive-action" = danger */
button.destructive-action {
background: @bg_3;
color: @error_color;
border-color: @error_color;
}
/* Bouton plat (toolbar) */
button.flat {
background: transparent;
border-color: transparent;
box-shadow: none;
}
button.flat:hover {
background: @bg_3;
}
/* Champs de saisie */
entry,
text {
background: @bg_1;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 8px;
padding: 8px 12px;
box-shadow: inset 0 1px 2px alpha(black, 0.3);
}
entry:focus,
text:focus {
border-color: @accent_color;
outline: 2px solid alpha(@accent_color, 0.18);
outline-offset: -1px;
}
/* Listes / treeview */
list,
treeview {
background: @bg_2;
color: @ink_1;
}
list > row {
padding: 8px 12px;
border-bottom: 1px solid @border_1;
}
list > row:selected,
treeview:selected {
background: alpha(@accent_color, 0.12);
color: @ink_1;
border-left: 3px solid @accent_color;
}
/* Switch (toggle) */
switch {
background: @bg_4;
border: 1px solid @border_2;
border-radius: 12px;
box-shadow: inset 0 1px 2px alpha(black, 0.4);
min-height: 22px;
min-width: 42px;
}
switch:checked {
background: @accent_color;
border-color: @accent_soft;
box-shadow: 0 0 10px alpha(@accent_color, 0.35);
}
switch slider {
background: @ink_2;
border-radius: 50%;
min-width: 18px;
min-height: 18px;
}
switch:checked slider {
background: @accent_fg_color;
}
/* Scale (slider) */
scale trough {
background: @bg_1;
border-radius: 4px;
min-height: 6px;
}
scale highlight {
background: @accent_color;
border-radius: 4px;
}
scale slider {
background: @ink_1;
border: 2px solid @accent_color;
border-radius: 50%;
min-width: 16px;
min-height: 16px;
box-shadow: 0 1px 4px alpha(black, 0.5);
}
/* Progress bar (jauge horizontale type batterie) */
progressbar trough {
background: @bg_1;
border: 1px solid @border_2;
border-radius: 4px;
box-shadow: inset 0 1px 2px alpha(black, 0.4);
min-height: 12px;
}
progressbar progress {
background: @success_color;
border-radius: 3px;
box-shadow: 0 0 8px alpha(@success_color, 0.45);
}
/* Niveaux de progression sémantiques (à appliquer via add_css_class) */
progressbar.warning progress { background: @warning_color; }
progressbar.error progress { background: @error_color; }
progressbar.info progress { background: @info_color; }
/* Notebook / onglets */
notebook header {
background: @bg_2;
border-bottom: 1px solid @border_2;
}
notebook tab {
padding: 8px 16px;
color: @ink_3;
border-top: 2px solid transparent;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
notebook tab:checked {
color: @ink_1;
border-top-color: @accent_color;
background: @bg_3;
}
/* Popover */
popover contents {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 32px alpha(black, 0.55);
}
/* Menubutton / dropdown */
menubutton button {
padding: 4px 8px;
}
/* Status pill (badge) — à appliquer sur GtkLabel.status */
label.status {
padding: 2px 8px;
border-radius: 999px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
label.status.ok { background: alpha(@success_color, 0.18); color: @success_color; }
label.status.warn { background: alpha(@warning_color, 0.18); color: @warning_color; }
label.status.error { background: alpha(@error_color, 0.18); color: @error_color; }
label.status.info { background: alpha(@info_color, 0.18); color: @info_color; }
/* Texte monospace / terminal */
label.mono,
.mono {
font-family: 'JetBrains Mono', monospace;
}
label.terminal,
.terminal {
font-family: 'Share Tech Mono', 'VT323', monospace;
letter-spacing: 0.02em;
}
/* Carte tuile (à appliquer via add_css_class("tile")) */
.tile,
.card {
background: @bg_3;
color: @ink_1;
border: 1px solid @border_2;
border-radius: 12px;
padding: 14px;
box-shadow:
inset 0 1px 0 alpha(white, 0.06),
inset 0 -1px 0 alpha(black, 0.4),
0 2px 4px alpha(black, 0.4),
0 6px 14px alpha(black, 0.45);
}
/* Scrollbar */
scrollbar slider {
background: @border_2;
border-radius: 4px;
min-width: 6px;
min-height: 6px;
}
scrollbar slider:hover {
background: @accent_soft;
}
/* ============================================================
THÈME CLAIR — à charger en alternative
Pour appliquer le thème clair, charger ce fichier puis
`tokens.gnome.light.css` (à dupliquer en remplaçant
les @define-color des fonds et encres) OU appliquer
un settings GTK light :
g_object_set(gtk_settings, "gtk-application-prefer-dark-theme",
FALSE, NULL);
Et fournir un fichier dérivé avec les valeurs ci-dessous :
============================================================ */
/*
bg_0: #b8b2a3
bg_1: #d5d0c5
bg_2: #dcd7cc
bg_3: #e3ded3
bg_4: #ccc6b8
bg_5: #bdb6a7
ink_1: #28241f
ink_2: #3c3836
ink_3: #5a544c
ink_4: #8a8278
accent_color: #af3a03
success_color: #3c911c
warning_color: #b57614
error_color: #9d0006
info_color: #427b58
blue_color: #2d82a3
purple_color: #8c468c
*/
@@ -0,0 +1,136 @@
{
"$schema": "design-tokens-v1",
"name": "mon design system — gruvbox seventies",
"version": "1.0.0",
"description": "Design system Gruvbox seventies. Orange brûlé, fond brun délavé en sombre / gris clair usé en clair. Deux thèmes dark/light parfaitement à parité.",
"themes": {
"dark": {
"bg": {
"0": { "value": "#221c17", "description": "Niveau le plus profond, rare" },
"1": { "value": "#2a231d", "description": "Fond application principal" },
"2": { "value": "#322a23", "description": "Panneaux (sidebar, headerbar)" },
"3": { "value": "#3c332a", "description": "Cartes, tuiles" },
"4": { "value": "#4a4035", "description": "Hover, état actif" },
"5": { "value": "#5a4f43", "description": "Press, sélection forte" }
},
"ink": {
"1": { "value": "#f2e5c7", "description": "Texte principal (cream)" },
"2": { "value": "#d5c4a1", "description": "Texte secondaire" },
"3": { "value": "#a89984", "description": "Labels, hints" },
"4": { "value": "#7c6f64", "description": "Désactivé" }
},
"accent": {
"primary": { "value": "#fe8019", "description": "Orange Gruvbox seventies" },
"soft": { "value": "#d65d0e", "description": "Orange foncé (hover, bordures)" },
"glow": { "value": "rgba(254, 128, 25, 0.35)" },
"tint": { "value": "rgba(254, 128, 25, 0.12)" }
},
"status": {
"ok": { "value": "#4dbb26" },
"warn": { "value": "#fabd2f" },
"err": { "value": "#fb4934" },
"info": { "value": "#83a598" }
},
"extra": {
"blue": { "value": "#3db0d1" },
"purple": { "value": "#c882c8" }
},
"border": {
"1": { "value": "rgba(168, 153, 132, 0.18)" },
"2": { "value": "rgba(168, 153, 132, 0.32)" },
"3": { "value": "rgba(168, 153, 132, 0.55)" }
}
},
"light": {
"bg": {
"0": { "value": "#b8b2a3", "description": "Niveau le plus profond" },
"1": { "value": "#d5d0c5", "description": "Fond application principal" },
"2": { "value": "#dcd7cc", "description": "Panneaux" },
"3": { "value": "#e3ded3", "description": "Cartes, tuiles" },
"4": { "value": "#ccc6b8", "description": "Hover" },
"5": { "value": "#bdb6a7", "description": "Press" }
},
"ink": {
"1": { "value": "#28241f", "description": "Texte principal" },
"2": { "value": "#3c3836", "description": "Texte secondaire" },
"3": { "value": "#5a544c", "description": "Labels, hints" },
"4": { "value": "#8a8278", "description": "Désactivé" }
},
"accent": {
"primary": { "value": "#af3a03", "description": "Orange brûlé (variante contrastée)" },
"soft": { "value": "#d65d0e" },
"glow": { "value": "rgba(175, 58, 3, 0.28)" },
"tint": { "value": "rgba(175, 58, 3, 0.08)" }
},
"status": {
"ok": { "value": "#3c911c" },
"warn": { "value": "#b57614" },
"err": { "value": "#9d0006" },
"info": { "value": "#427b58" }
},
"extra": {
"blue": { "value": "#2d82a3" },
"purple": { "value": "#8c468c" }
},
"border": {
"1": { "value": "rgba(60, 56, 54, 0.15)" },
"2": { "value": "rgba(60, 56, 54, 0.28)" },
"3": { "value": "rgba(60, 56, 54, 0.5)" }
}
}
},
"typography": {
"fonts": {
"ui": { "family": "Inter", "weights": [400, 500, 600, 700], "fallback": ["Cantarell", "system-ui", "sans-serif"] },
"mono": { "family": "JetBrains Mono", "weights": [400, 500, 600, 700], "fallback": ["ui-monospace", "monospace"] },
"terminal": { "family": "Share Tech Mono", "weights": [400], "fallback": ["VT323", "Courier New", "monospace"] }
},
"scale": {
"label": { "size": 11, "weight": 500, "transform": "uppercase", "tracking": "0.08em", "family": "mono" },
"caption": { "size": 12, "weight": 400, "family": "ui" },
"body": { "size": 14, "weight": 400, "family": "ui" },
"body-emph": { "size": 14, "weight": 600, "family": "ui" },
"title": { "size": 18, "weight": 700, "family": "ui" },
"h2": { "size": 22, "weight": 700, "family": "ui" },
"h1": { "size": 28, "weight": 700, "family": "ui" },
"display": { "size": 44, "weight": 700, "family": "ui" },
"kpi": { "size": 28, "weight": 700, "family": "mono" }
}
},
"radius": {
"xs": 3,
"sm": 4,
"md": 6,
"lg": 8,
"xl": 10,
"2xl": 12,
"pill": 999
},
"spacing": {
"1": 4,
"2": 6,
"3": 8,
"4": 10,
"5": 12,
"6": 14,
"7": 16,
"8": 18,
"9": 20,
"10": 24,
"12": 32,
"14": 40,
"16": 56
},
"shadows": {
"1": "0 1px 2px rgba(0,0,0,0.4)",
"2": "0 4px 12px rgba(0,0,0,0.45)",
"3": "0 12px 32px rgba(0,0,0,0.55)",
"press": "inset 0 2px 4px rgba(0,0,0,0.5)",
"tile3d": "inset 0 1px 0 rgba(255,230,180,0.12), inset 0 -1px 0 rgba(0,0,0,0.45), 0 1px 0 rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 8px 18px rgba(0,0,0,0.5)"
},
"motion": {
"fast": "60ms ease",
"normal": "180ms cubic-bezier(.3,.7,.3,1.2)",
"slow": "400ms cubic-bezier(.3,.6,.3,1)"
}
}
+109
View File
@@ -0,0 +1,109 @@
# Environnement de PRODUCTION — images publiées sur le registre OCI Gitea.
# Aucune image n'est reconstruite ici (pas de build:). Source unique de vérité :
# git.maison43gil.com/gilles/home_hub
#
# Utilisation :
# docker login git.maison43gil.com
# docker compose -f docker-compose.deploy.yml pull
# docker compose -f docker-compose.deploy.yml up -d
#
# Les secrets proviennent du fichier .env (voir .env.example).
services:
db:
image: postgres:16-alpine
container_name: home_hub_db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-homehub}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}
POSTGRES_DB: ${POSTGRES_DB:-homehub}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-homehub}"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: home_hub_redis
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
# Applique les migrations Alembic puis se termine. Le backend attend son succès.
backend-migrate:
image: git.maison43gil.com/gilles/home_hub:backend-latest
container_name: home_hub_migrate
user: "1000:1000"
command: alembic upgrade head
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-homehub}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}@db:5432/${POSTGRES_DB:-homehub}
DATA_DIR: /data
volumes:
- ./data:/data
depends_on:
db:
condition: service_healthy
backend:
image: git.maison43gil.com/gilles/home_hub:backend-latest
container_name: home_hub_backend
restart: unless-stopped
user: "1000:1000"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-homehub}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}@db:5432/${POSTGRES_DB:-homehub}
UPLOAD_DIR: /data/uploads
DATA_DIR: /data
REDIS_URL: redis://redis:6379
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3001}
MCP_API_KEY: ${MCP_API_KEY:?MCP_API_KEY requis}
volumes:
- ./data:/data
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
backend-migrate:
condition: service_completed_successfully
backend-worker:
image: git.maison43gil.com/gilles/home_hub:backend-latest
container_name: home_hub_worker
restart: unless-stopped
user: "1000:1000"
command: arq app.workers.notes_worker.WorkerSettings
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-homehub}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD requis}@db:5432/${POSTGRES_DB:-homehub}
UPLOAD_DIR: /data/uploads
DATA_DIR: /data
REDIS_URL: redis://redis:6379
volumes:
- ./data:/data
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
backend-migrate:
condition: service_completed_successfully
frontend:
image: git.maison43gil.com/gilles/home_hub:frontend-latest
container_name: home_hub_frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3001}:80"
depends_on:
- backend
volumes:
db_data:
redis_data:
+4 -1
View File
@@ -25,12 +25,14 @@ services:
backend:
build: ./backend
user: "1000:1000"
environment:
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
UPLOAD_DIR: /data/uploads
DATA_DIR: /data
REDIS_URL: redis://redis:6379
CORS_ORIGINS: http://localhost:3001,http://localhost:3000
CORS_ORIGINS: http://localhost:3001,http://localhost:3000,http://10.0.1.45:3001
MCP_API_KEY: ${MCP_API_KEY}
volumes:
- ./backend/app:/app/app
- ./data:/data
@@ -44,6 +46,7 @@ services:
backend-worker:
build: ./backend
user: "1000:1000"
command: arq app.workers.notes_worker.WorkerSettings
environment:
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
+55
View File
@@ -150,6 +150,38 @@
---
## Phase 4c — Notes v2 : états de tuile + médias ✅
**Objectif** : refonte de l'interface Notes avec tuiles à 3 états, support vidéo, transcodage audio universel.
### Backend
- [x] `ffmpeg` dans le Dockerfile backend — transcodage audio et vidéo
- [x] `save_audio()` : transcode toute entrée (webm/ogg/m4a) → AAC `.m4a` — lecture Safari iOS garantie
- [x] `save_video()` : stockage mp4/quicktime direct, webm → H.264/mp4 via ffmpeg
- [x] `ALLOWED_VIDEO_TYPES` : mp4, webm, quicktime, m4v, 3gpp
- [x] `GET /api/notes` : filtre `has_video` ajouté
- [x] `POST /api/notes/{id}/attachments` : gère `file_type = "video"`
- [x] `GET /api/admin/stats` : section `media.video` (count + size_bytes)
- [x] Schémas Pydantic notes : `gps_lat/gps_lon` passés en `float | None` (fix `Decimal` sérialisé en string → TypeError JS)
### Frontend
- [x] NoteCard à 3 états : **semi** (défaut, 3 lignes + actions) / **expanded** (markdown complet + médias) / **collapsed** (titre + date)
- [x] Bouton toggle `fa-chevron-down / fa-minus / fa-chevron-right` dans le coin haut-droit de chaque tuile
- [x] Renderer pseudo-markdown : `# ## ###`, `- * 1.` listes, `> citations`, `---`, `` **gras** *italique* `code` ``, ` ``` ` blocs
- [x] Icônes méta sur la tuile : `fa-image` / `fa-microphone` / `fa-video` / `fa-location-dot`
- [x] Bouton vidéo `fa-video` dans les actions de chaque note
- [x] Lecteur `<video playsInline>` inline dans l'état expanded
- [x] Audio : `onLoadedMetadata` règle le volume à 50% + fix overflow (`minWidth: 0`)
- [x] Filtres rapides : Photo / Audio / Vidéo / GPS (avec icônes Font Awesome)
- [x] Grille : 3 colonnes max sur laptop (`repeat(3, 1fr)`), 1 colonne sur mobile
- [x] Bouton "Nouvelle note" dans le header, visible sur laptop uniquement
- [x] Sidebar laptop : indicateur de statut BDD (LED verte/rouge + taille) avec polling 30s sur `/api/health`
- [x] ConfigPage : grille médias 3 colonnes (Photos / Audio / Vidéos)
- [x] `client_max_body_size nginx` : 15m → 200m pour les vidéos
- [x] `docker-compose.yml` : `user: "1000:1000"` sur backend et backend-worker
---
## Phase 4b — UX transversale ✅
**Objectif** : améliorations d'expérience applicables à tous les modules.
@@ -286,6 +318,29 @@
- Endpoint analyse frigo (photo → suggestions liste de courses)
- Amélioration OCR via modèle Vision local (Ollama)
### Phase 12 — Éditeur Markdown pour les Notes
Refonte du module Notes autour d'un vrai éditeur Markdown orienté mobile.
**Concept** : une seule interface unifiée — champ titre + corps éditeur — avec une barre d'outils flottante au-dessus du clavier.
**Barre d'outils (ordre d'affichage)** :
- `H` — Titre (`# `)
- `•` — Liste à puces (`- `)
- `1.` — Liste numérotée (`1. `)
- `</>` — Bloc code (`` ` `` inline ou ` ``` ` bloc)
- 📷 — Insérer image (upload direct, syntaxe `![](path)`)
- 🎤 — Enregistrement audio (MediaRecorder, lien `[enregistrement.m4a](path)`)
- 📍 — Position GPS (insère `[GPS](geo:lat,lon)` ou coordonnées brutes)
**Rendu** : aperçu Markdown en lecture (pas d'édition WYSIWYG pure — mode split ou toggle édit/aperçu).
**Contraintes techniques** :
- Bibliothèque candidate : `@uiw/react-md-editor` (légère, compatible mobile) ou éditeur custom avec `textarea` + parsing `marked`/`micromark`
- La barre d'outils doit rester au-dessus du clavier virtuel iOS/Android (même logique que `BottomSheet` avec `visualViewport`)
- Compatibilité avec le format des fichiers `.md` déjà exportés en Phase 5 (frontmatter YAML conservé)
- Le backend ne change pas : `content` reste un champ texte libre (Markdown brut stocké tel quel)
---
## Ordre de développement
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
# MCP Server — Design Spec
## Objectif
Intégrer un serveur MCP (Model Context Protocol) directement dans le backend FastAPI de HomeHub, exposant 16 outils aux agents IA (Hermes, Claude Code, Codex) via le transport Streamable HTTP (standard 2025).
---
## Architecture
```
Hermes (10.0.0.80)
│ POST/GET http://10.0.0.50:3001/mcp (via nginx)
nginx (frontend container)
│ proxy_pass → http://backend:8000/mcp
│ proxy_buffering off, proxy_read_timeout 86400s
FastAPI backend
├── MCPAuthMiddleware → vérifie Authorization: Bearer <MCP_API_KEY>
└── /mcp ← MCP Server (Streamable HTTP, SDK officiel mcp>=1.9)
├── 5 outils Todos → SQLAlchemy async (pool partagé)
├── 5 outils Notes → SQLAlchemy async (pool partagé)
└── 6 outils Shopping → SQLAlchemy async (pool partagé)
```
Le serveur MCP partage le pool de connexions PostgreSQL du backend. Il n'y a pas de service Docker supplémentaire.
---
## Authentification
- Header requis : `Authorization: Bearer <MCP_API_KEY>`
- `MCP_API_KEY` définie dans `.env` / `docker-compose.yml` (variable d'environnement)
- Un middleware FastAPI intercepte toutes les requêtes `/mcp*` et renvoie HTTP 401 si le token est absent ou incorrect
- Les autres routes du backend (`/api/*`) ne sont pas affectées
---
## Outils exposés
### Todos (5 outils)
| Outil | Paramètres | Description |
|-------|-----------|-------------|
| `get_todos` | `status: str = "pending"`, `domain: str?`, `priority: str?` | Liste filtrée des tâches |
| `create_todo` | `title: str`, `due_date: str?` (ISO 8601), `priority: str?`, `domain: str?` | Crée une tâche |
| `update_todo` | `id: str`, `title: str?`, `status: str?`, `priority: str?` | Modifie une tâche |
| `postpone_todo` | `id: str`, `days: int` | Reporte la date d'échéance |
| `delete_todo` | `id: str` | Supprime une tâche |
### Notes (5 outils)
| Outil | Paramètres | Description |
|-------|-----------|-------------|
| `search_notes` | `query: str?`, `category: str?`, `tag: str?` | Recherche FTS PostgreSQL (français) |
| `get_note` | `id: str` | Retourne une note complète avec pièces jointes |
| `create_note` | `title: str`, `content: str`, `category: str?`, `tags: list[str]?` | Crée une note |
| `update_note` | `id: str`, `title: str?`, `content: str?`, `tags: list[str]?` | Modifie une note |
| `delete_note` | `id: str` | Supprime une note |
### Shopping (6 outils)
| Outil | Paramètres | Description |
|-------|-----------|-------------|
| `get_shopping_lists` | — | Toutes les listes avec compteurs d'articles |
| `get_active_shopping_list` | — | Première liste en statut `draft` avec ses articles |
| `search_products` | `q: str` | Recherche dans le catalogue produits |
| `create_shopping_list` | `name: str?` | Crée une liste (nom auto = semaine ISO si absent) |
| `add_shopping_item` | `list_id: str`, `name: str`, `quantity: float? = 1`, `unit: str?` | Ajoute un article à une liste |
| `check_shopping_item` | `list_id: str`, `item_id: str` | Coche un article (marque comme acheté) |
---
## Fichiers à créer/modifier
| Fichier | Action | Rôle |
|---------|--------|------|
| `backend/app/api/mcp_server.py` | Créer | Définition des 16 outils MCP, logique métier |
| `backend/app/core/mcp_auth.py` | Créer | Middleware Bearer token pour `/mcp*` |
| `backend/app/main.py` | Modifier | Mount du serveur MCP + enregistrement middleware |
| `backend/requirements.txt` | Modifier | Ajout `mcp>=1.9` |
| `backend/app/core/config.py` | Modifier | Ajout champ `mcp_api_key: str` dans Settings |
| `docker-compose.yml` | Modifier | Ajout variable `MCP_API_KEY` |
| `frontend/nginx.conf` | Modifier | Location `/mcp` dédiée (no-buffer, long timeout) |
---
## Clé API générée
```
MCP_API_KEY=4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI
```
À ajouter dans `docker-compose.yml` sous `backend.environment` et dans tout fichier `.env` local.
---
## Configuration Hermes (post-déploiement)
```yaml
servers:
homehub:
url: http://10.0.0.50:3001/mcp
headers:
Authorization: "Bearer 4zfCmiC3Z_28F3xPOxBSDi0DQx6aRzAQrpplyywo_VI"
```
Après configuration : `/reload-mcp` dans l'interface Hermes pour prendre en compte les nouveaux outils.
---
## Retours des outils
Chaque outil retourne du JSON structuré cohérent avec les schémas Pydantic existants du backend. En cas d'erreur (ID introuvable, paramètre invalide), l'outil retourne un objet `{"error": "<message>"}` sans lever d'exception MCP (pour ne pas interrompre le contexte de l'agent).
---
## Dépendances
- `mcp>=1.9` — SDK officiel Anthropic (Streamable HTTP transport)
- Aucune autre dépendance externe — SQLAlchemy, FastAPI, Pydantic déjà présents
---
## Hors périmètre
- Authentification OAuth2 / multi-utilisateur (réseau local, clé statique suffit)
- Outils de gestion des pièces jointes (upload binaire incompatible MCP)
- Analyse frigo Vision LLM (Phase 9 séparée)
- Websocket / stdio transport (Hermes utilise HTTP)
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.*
*.log
.vite/
+1 -1
View File
@@ -5,7 +5,7 @@ RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
FROM public.ecr.aws/nginx/nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+1 -1
View File
@@ -4,12 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#fe8019" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>HomeHub</title>
<!-- Anti-flash : applique thème et zoom avant le premier rendu -->
<script>
+23 -1
View File
@@ -4,7 +4,7 @@ server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 15m;
client_max_body_size 200m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
@@ -14,6 +14,28 @@ server {
add_header Cache-Control "public, immutable";
}
location /api/events/stream {
proxy_pass http://backend:8000/api/events/stream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
location /mcp {
proxy_pass http://backend:8000/mcp;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.5.2",
"version": "0.5.17",
"type": "module",
"scripts": {
"dev": "vite",
+10 -2
View File
@@ -1,5 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#282828"/>
<path d="M16 5L4 15.5h3V27h7.5v-7h3v7H25V15.5h3L16 5z" fill="#fe8019"/>
<rect x="13.5" y="19" width="5" height="8" fill="#282828"/>
<!-- Lignes hub -->
<line x1="16" y1="15" x2="16" y2="6" stroke="#fe8019" stroke-width="1.5" stroke-opacity="0.45"/>
<line x1="16" y1="15" x2="7" y2="24" stroke="#fe8019" stroke-width="1.5" stroke-opacity="0.45"/>
<line x1="16" y1="15" x2="25" y2="24" stroke="#fe8019" stroke-width="1.5" stroke-opacity="0.45"/>
<!-- Nœuds satellites -->
<circle cx="16" cy="6" r="2.5" fill="#d5c4a1"/>
<circle cx="7" cy="24" r="2.5" fill="#d5c4a1"/>
<circle cx="25" cy="24" r="2.5" fill="#d5c4a1"/>
<!-- Nœud central -->
<circle cx="16" cy="15" r="5.5" fill="#fe8019"/>
</svg>

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+41 -20
View File
@@ -3,6 +3,7 @@ export interface AppStats {
media: {
photos: { count: number; size_bytes: number }
audio: { count: number; size_bytes: number }
video: { count: number; size_bytes: number }
}
counts: {
notes: number
@@ -17,31 +18,51 @@ export async function fetchStats(): Promise<AppStats> {
return res.json() as Promise<AppStats>
}
export interface BackupFile {
filename: string
size: number
created_at: string
}
export async function fetchBackups(): Promise<BackupFile[]> {
const res = await fetch('/api/admin/backups')
if (!res.ok) throw new Error('Erreur chargement sauvegardes')
return res.json() as Promise<BackupFile[]>
}
export async function createBackup(): Promise<BackupFile> {
export async function downloadBackup(): Promise<string> {
const res = await fetch('/api/admin/backup', { method: 'POST' })
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { detail?: string }
throw new Error(err.detail ?? 'Erreur lors du backup')
}
return res.json() as Promise<BackupFile>
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') ?? ''
const match = /filename="?([^";]+)"?/.exec(disposition)
const filename = match?.[1] ?? `homehub_${new Date().toISOString().slice(0, 10)}.tar.gz`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
return filename
}
export async function restoreBackup(filename: string): Promise<void> {
const res = await fetch(`/api/admin/restore/${encodeURIComponent(filename)}`, { method: 'POST' })
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { detail?: string }
throw new Error(err.detail ?? 'Erreur lors de la restauration')
}
export async function uploadAndRestore(file: File, onProgress?: (pct: number) => void): Promise<void> {
await new Promise<void>((resolve, reject) => {
const form = new FormData()
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/admin/restore')
if (onProgress) {
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) onProgress(Math.round((ev.loaded / ev.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else {
try {
const err = JSON.parse(xhr.responseText) as { detail?: string }
reject(new Error(err.detail ?? `Erreur ${xhr.status}`))
} catch {
reject(new Error(`Erreur ${xhr.status}`))
}
}
}
xhr.onerror = () => reject(new Error('Erreur réseau'))
xhr.send(form)
})
}
+10 -1
View File
@@ -1,8 +1,13 @@
export interface NoteUrl {
label: string
url: string
}
export interface NoteAttachment {
id: string
file_path: string | null
thumbnail_path: string | null
file_type: 'image' | 'audio' | null
file_type: 'image' | 'audio' | 'video' | null
original_name: string | null
created_at: string
}
@@ -15,6 +20,7 @@ export interface Note {
tags: string[]
gps_lat: number | null
gps_lon: number | null
urls: NoteUrl[]
created_at: string
attachments: NoteAttachment[]
}
@@ -26,6 +32,7 @@ export interface NoteCreate {
tags?: string[]
gps_lat?: number
gps_lon?: number
urls?: NoteUrl[]
}
export interface NoteFilters {
@@ -34,6 +41,7 @@ export interface NoteFilters {
tag?: string
has_photo?: boolean
has_audio?: boolean
has_video?: boolean
has_gps?: boolean
}
@@ -46,6 +54,7 @@ export async function fetchNotes(filters: NoteFilters = {}): Promise<Note[]> {
if (filters.tag) params.set('tag', filters.tag)
if (filters.has_photo !== undefined) params.set('has_photo', String(filters.has_photo))
if (filters.has_audio !== undefined) params.set('has_audio', String(filters.has_audio))
if (filters.has_video !== undefined) params.set('has_video', String(filters.has_video))
if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps))
const res = await fetch(`${BASE}/?${params}`)
if (!res.ok) throw new Error('Erreur chargement notes')
+15
View File
@@ -82,11 +82,15 @@ export interface ShoppingItem {
price_recorded: string | null
carried_over: boolean
sort_order: number | null
url: string | null
description: string | null
image_url: string | null
}
export interface ShoppingList {
id: string
name: string | null
list_type: 'weekly' | 'project'
store_id: string | null
week_date: string | null
status: 'draft' | 'active' | 'done'
@@ -101,6 +105,7 @@ export interface ShoppingListDetail extends ShoppingList {
export interface ShoppingListCreate {
name?: string
list_type?: 'weekly' | 'project'
store_id?: string
week_date?: string
}
@@ -116,6 +121,9 @@ export interface ShoppingItemCreate {
custom_name?: string
quantity?: string
unit?: string
url?: string
description?: string
image_url?: string
}
export interface ShoppingItemUpdate {
@@ -123,6 +131,9 @@ export interface ShoppingItemUpdate {
quantity?: string
unit?: string
price_recorded?: string
url?: string
description?: string
image_url?: string
}
const BASE = '/api/shopping'
@@ -201,6 +212,10 @@ export async function createList(data: ShoppingListCreate): Promise<ShoppingList
}))
}
export async function createProjectList(name: string, storeId?: string): Promise<ShoppingListDetail> {
return createList({ name, list_type: 'project', store_id: storeId })
}
export async function fetchListDetail(id: string): Promise<ShoppingListDetail> {
return handleResponse(await fetch(`${BASE}/lists/${id}`))
}
+1 -1
View File
@@ -38,7 +38,7 @@ function TopBar() {
padding: '0 12px', gap: 8,
}}>
{/* Identité app — gauche */}
<i className="fa-solid fa-house" style={{ color: 'var(--accent)', fontSize: 16, flexShrink: 0 }} />
<i className="fa-solid fa-circle-nodes" style={{ color: 'var(--accent)', fontSize: 16, flexShrink: 0 }} />
<span style={{
fontFamily: 'var(--font-mono)', fontWeight: 700,
color: 'var(--accent)', fontSize: 15, letterSpacing: '-0.02em',
+95 -25
View File
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react'
import { NavLink } from 'react-router-dom'
const NAV_ITEMS = [
@@ -7,6 +8,71 @@ const NAV_ITEMS = [
{ to: '/notes', icon: 'note-sticky', label: 'Notes' },
]
type DbStatus = 'ok' | 'error' | 'checking'
function DbStatusBar() {
const [status, setStatus] = useState<DbStatus>('checking')
const [dbSize, setDbSize] = useState<string | null>(null)
async function check() {
try {
const res = await fetch('/api/health', { signal: AbortSignal.timeout(3000) })
if (!res.ok) { setStatus('error'); return }
setStatus('ok')
// Récupère la taille BDD en parallèle (silencieux si KO)
fetch('/api/admin/stats')
.then(r => r.ok ? r.json() : null)
.then((d: { db_size_bytes?: number } | null) => {
if (d?.db_size_bytes) {
const bytes = d.db_size_bytes
setDbSize(bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} Ko` : `${(bytes / 1024 / 1024).toFixed(1)} Mo`)
}
})
.catch(() => null)
} catch {
setStatus('error')
}
}
useEffect(() => {
void check()
const id = setInterval(() => void check(), 30_000)
return () => clearInterval(id)
}, [])
const color = status === 'ok' ? 'var(--ok)' : status === 'error' ? 'var(--err)' : 'var(--ink-4)'
const label = status === 'ok' ? 'BDD connectée' : status === 'error' ? 'BDD hors ligne' : 'Vérification…'
return (
<div style={{
padding: '10px 16px',
borderTop: '1px solid var(--border-1)',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
{/* LED status */}
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: color,
flexShrink: 0,
boxShadow: status === 'ok' ? `0 0 6px ${color}` : 'none',
transition: 'background 0.4s, box-shadow 0.4s',
}} />
<div style={{ minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-3)', whiteSpace: 'nowrap' }}>
{label}
</div>
{dbSize && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--ink-4)' }}>
{dbSize}
</div>
)}
</div>
</div>
)
}
export default function SideNav() {
return (
<nav style={{
@@ -15,37 +81,41 @@ export default function SideNav() {
borderRight: '1px solid var(--border-1)',
display: 'flex',
flexDirection: 'column',
padding: '16px 0',
height: '100%',
}}>
<div style={{ padding: '0 16px 16px', borderBottom: '1px solid var(--border-1)', marginBottom: 8 }}>
<div style={{ padding: '16px 16px 16px', borderBottom: '1px solid var(--border-1)' }}>
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 16 }}>
HomeHub
</span>
</div>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 16px',
color: isActive ? 'var(--accent)' : 'var(--ink-2)',
background: isActive ? 'var(--accent-tint)' : 'transparent',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
textDecoration: 'none',
fontFamily: 'var(--font-ui)',
fontSize: 14,
minHeight: 40,
})}
>
<i className={`fa-solid fa-${item.icon}`} style={{ width: 18 }} />
{item.label}
</NavLink>
))}
<div style={{ flex: 1, paddingTop: 8 }}>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 16px',
color: isActive ? 'var(--accent)' : 'var(--ink-2)',
background: isActive ? 'var(--accent-tint)' : 'transparent',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
textDecoration: 'none',
fontFamily: 'var(--font-ui)',
fontSize: 14,
minHeight: 40,
})}
>
<i className={`fa-solid fa-${item.icon}`} style={{ width: 18 }} />
{item.label}
</NavLink>
))}
</div>
<DbStatusBar />
</nav>
)
}
+97 -2
View File
@@ -1,5 +1,5 @@
import { useState, useRef } from 'react'
import type { Note, NoteCreate } from '../../api/notes'
import type { Note, NoteCreate, NoteUrl } from '../../api/notes'
interface NoteFormProps {
initialValues?: Note
@@ -22,11 +22,24 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box',
}
const labelStyle: React.CSSProperties = {
color: 'var(--ink-3)',
fontSize: 11,
fontFamily: 'var(--font-ui)',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
}
export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabel = 'Créer' }: NoteFormProps) {
const [title, setTitle] = useState(initialValues?.title ?? '')
const [content, setContent] = useState(initialValues?.content ?? '')
const [category, setCategory] = useState(initialValues?.category ?? '')
const [tagInput, setTagInput] = useState(initialValues?.tags.join(', ') ?? '')
const [urls, setUrls] = useState<NoteUrl[]>(initialValues?.urls ?? [])
const [urlLabel, setUrlLabel] = useState('')
const [urlHref, setUrlHref] = useState('')
const [urlError, setUrlError] = useState<string | null>(null)
const [gpsLat, setGpsLat] = useState<number | undefined>(initialValues?.gps_lat ?? undefined)
const [gpsLon, setGpsLon] = useState<number | undefined>(initialValues?.gps_lon ?? undefined)
const [gpsLoading, setGpsLoading] = useState(false)
@@ -40,6 +53,24 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
return raw.split(',').map(t => t.trim()).filter(Boolean)
}
function addUrl() {
const href = urlHref.trim()
const label = urlLabel.trim() || href
if (!href) return
if (!href.startsWith('http://') && !href.startsWith('https://')) {
setUrlError('URL doit commencer par http:// ou https://')
return
}
setUrls(prev => [...prev, { label, url: href }])
setUrlLabel('')
setUrlHref('')
setUrlError(null)
}
function removeUrl(idx: number) {
setUrls(prev => prev.filter((_, i) => i !== idx))
}
function handleGps() {
setGpsError(null)
if (!navigator.geolocation) {
@@ -84,6 +115,7 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
tags: parseTags(tagInput),
gps_lat: gpsLat,
gps_lon: gpsLon,
urls: urls.length > 0 ? urls : [],
})
} catch {
setError('Erreur lors de la sauvegarde')
@@ -131,6 +163,69 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
/>
</div>
{/* URLs */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={labelStyle}>Liens</div>
{urls.map((u, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-4)', borderRadius: 8, padding: '6px 10px' }}>
<i className="fa-solid fa-link" style={{ color: 'var(--ink-4)', fontSize: 12, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.label}
</div>
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.url}
</div>
</div>
<button
type="button"
onClick={() => removeUrl(idx)}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0, padding: '2px 4px' }}
></button>
</div>
))}
{/* Formulaire ajout */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 6 }}>
<input
style={{ ...inputStyle, flex: 1 }}
placeholder="Libellé (ex: Tuto vidéo)"
value={urlLabel}
onChange={e => setUrlLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addUrl())}
/>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input
style={{ ...inputStyle, flex: 1 }}
placeholder="https://…"
value={urlHref}
onChange={e => { setUrlHref(e.target.value); setUrlError(null) }}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addUrl())}
type="url"
/>
<button
type="button"
onClick={addUrl}
disabled={!urlHref.trim()}
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: urlHref.trim() ? 'var(--accent)' : 'var(--bg-5)',
color: urlHref.trim() ? '#1d2021' : 'var(--ink-4)',
cursor: urlHref.trim() ? 'pointer' : 'default',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
minHeight: 36, flexShrink: 0,
}}
>+ Ajouter</button>
</div>
{urlError && (
<span style={{ color: 'var(--err)', fontSize: 11, fontFamily: 'var(--font-ui)' }}>{urlError}</span>
)}
</div>
</div>
{/* GPS */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
@@ -147,7 +242,7 @@ export default function NoteForm({ initialValues, onSubmit, onCancel, submitLabe
fontFamily: 'var(--font-ui)', fontSize: 13, minHeight: 36,
}}
>
<i className={`fa-solid fa-location-dot`} style={{ marginRight: 6 }} />
<i className="fa-solid fa-location-dot" style={{ marginRight: 6 }} />
{gpsLoading ? '…' : gpsLat != null ? 'GPS capturé' : 'Ajouter GPS'}
</button>
{gpsLat != null && (
@@ -0,0 +1,125 @@
import { useState } from 'react'
import type { ShoppingItem, Store } from '../../api/shopping'
interface ProjectItemCardProps {
item: ShoppingItem
stores: Store[]
onCheck: () => void
onDelete: () => void
onEdit: () => void
}
export default function ProjectItemCard({ item, stores, onCheck, onDelete, onEdit }: ProjectItemCardProps) {
const [imgError, setImgError] = useState(false)
const store = stores.find(s => s.id === item.product_id)
return (
<div style={{
background: item.is_checked ? 'rgba(142,192,124,0.08)' : 'var(--bg-3)',
borderRadius: 10,
overflow: 'hidden',
border: '1px solid var(--bg-4)',
opacity: item.is_checked ? 0.65 : 1,
transition: 'opacity 0.2s',
}}>
{/* Image */}
{item.image_url && !imgError && (
<div style={{ position: 'relative', height: 140, overflow: 'hidden', background: 'var(--bg-2)' }}>
<img
src={item.image_url}
alt={item.display_name}
onError={() => setImgError(true)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
)}
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Nom + actions */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<button
onClick={onCheck}
style={{
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${item.is_checked ? 'var(--ok)' : 'var(--bg-5)'}`,
background: item.is_checked ? 'var(--ok)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', transition: 'all 0.15s', marginTop: 2,
}}
>
{item.is_checked && <span style={{ color: '#1d2021', fontSize: 13, fontWeight: 700 }}></span>}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: item.is_checked ? 'var(--ink-3)' : 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 15, fontWeight: 600,
textDecoration: item.is_checked ? 'line-through' : 'none',
overflowWrap: 'anywhere',
}}>
{item.display_name}
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button
onClick={onEdit}
style={{ background: 'var(--bg-4)', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', color: 'var(--ink-3)', fontSize: 13 }}
title="Modifier"
><i className="fa-solid fa-pen" /></button>
<button
onClick={onDelete}
style={{ background: 'transparent', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', color: 'var(--ink-4)', fontSize: 13 }}
title="Supprimer"
><i className="fa-solid fa-xmark" /></button>
</div>
</div>
{/* Description */}
{item.description && (
<p style={{
margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)',
fontSize: 13, lineHeight: 1.5, overflowWrap: 'anywhere',
}}>
{item.description}
</p>
)}
{/* Meta : boutique + lien */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{store && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'var(--bg-4)', borderRadius: 999, padding: '2px 8px',
color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)',
}}>
<i className="fa-solid fa-store" style={{ fontSize: 9 }} />
{store.name}
</span>
)}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
color: 'var(--info)', fontFamily: 'var(--font-ui)', fontSize: 12,
textDecoration: 'none',
}}
>
<i className="fa-solid fa-arrow-up-right-from-square" style={{ fontSize: 10 }} />
Voir le produit
</a>
)}
{item.quantity && (
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
{item.quantity}{item.unit ? ` ${item.unit}` : ''}
</span>
)}
</div>
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useRef } from 'react'
export function useServerEvents(handlers: Record<string, () => void>) {
const handlersRef = useRef(handlers)
handlersRef.current = handlers
useEffect(() => {
let es: EventSource
let retryTimeout: ReturnType<typeof setTimeout>
function connect() {
es = new EventSource('/api/events/stream')
Object.keys(handlersRef.current).forEach(event => {
es.addEventListener(event, () => handlersRef.current[event]?.())
})
es.onerror = () => {
es.close()
retryTimeout = setTimeout(connect, 3000)
}
}
connect()
return () => {
es?.close()
clearTimeout(retryTimeout)
}
}, [])
}
+6 -1
View File
@@ -15,9 +15,14 @@ input, textarea, select {
user-select: text;
}
html, body {
margin: 0;
overflow-x: hidden;
max-width: 100vw;
}
body {
font-family: var(--font-ui);
background-color: var(--bg-1);
color: var(--ink-1);
margin: 0;
}
+71 -61
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useRef, useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
import { fetchBackups, createBackup, restoreBackup, fetchStats, type BackupFile, type AppStats } from '../api/admin'
import { downloadBackup, uploadAndRestore, fetchStats, type AppStats } from '../api/admin'
const sectionStyle: React.CSSProperties = {
background: 'var(--bg-3)',
@@ -39,30 +39,28 @@ function formatSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })
}
export default function ConfigPage() {
const navigate = useNavigate()
const { theme, setTheme, fontScale, setFontScale } = useTheme()
const [stats, setStats] = useState<AppStats | null>(null)
const [backups, setBackups] = useState<BackupFile[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [restoring, setRestoring] = useState(false)
const [restoreProgress, setRestoreProgress] = useState(0)
const [backupError, setBackupError] = useState<string | null>(null)
const [backupInfo, setBackupInfo] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetchStats().then(setStats).catch(() => null)
fetchBackups().then(setBackups).catch(() => setBackups([]))
}, [])
async function handleCreateBackup() {
setBackupLoading(true)
setBackupError(null)
setBackupInfo(null)
try {
const b = await createBackup()
setBackups(prev => [b, ...prev])
const filename = await downloadBackup()
setBackupInfo(`Téléchargé : ${filename}`)
} catch (e) {
setBackupError((e as Error).message)
} finally {
@@ -70,16 +68,22 @@ export default function ConfigPage() {
}
}
async function handleRestore(filename: string) {
if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return
setRestoring(filename)
async function handleRestoreFile(file: File) {
if (!confirm(`Restaurer depuis "${file.name}" ? La base actuelle (BDD + médias) sera remplacée.`)) return
setRestoring(true)
setRestoreProgress(0)
setBackupError(null)
setBackupInfo(null)
try {
await restoreBackup(filename)
await uploadAndRestore(file, pct => setRestoreProgress(pct))
setBackupInfo('Restauration réussie')
fetchStats().then(setStats).catch(() => null)
} catch (e) {
setBackupError((e as Error).message)
} finally {
setRestoring(null)
setRestoring(false)
setRestoreProgress(0)
if (fileRef.current) fileRef.current.value = ''
}
}
@@ -155,10 +159,11 @@ export default function ConfigPage() {
</div>
{/* Médias */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{[
{ icon: 'image', label: 'Photos', data: stats.media.photos },
{ icon: 'microphone', label: 'Audio', data: stats.media.audio },
{ icon: 'video', label: 'Vidéos', data: stats.media.video },
].map(item => (
<div key={item.label} style={{ background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`fa-solid fa-${item.icon}`} style={{ color: 'var(--info)', fontSize: 14, flexShrink: 0 }} />
@@ -188,10 +193,11 @@ export default function ConfigPage() {
{/* Sauvegarde & Restauration */}
<div style={sectionStyle}>
<div style={labelStyle}>Base de données</div>
<div style={labelStyle}>Sauvegarde complète (BDD + médias)</div>
<button
onClick={handleCreateBackup}
disabled={backupLoading}
disabled={backupLoading || restoring}
style={{
padding: '10px 16px', borderRadius: 8, border: 'none',
background: 'var(--ok)', color: '#1d2021',
@@ -201,59 +207,63 @@ export default function ConfigPage() {
opacity: backupLoading ? 0.6 : 1,
}}
>
<i className="fa-solid fa-database" />
{backupLoading ? 'Sauvegarde en cours…' : 'Créer une sauvegarde'}
<i className="fa-solid fa-download" />
{backupLoading ? 'Préparation…' : 'Télécharger une archive'}
</button>
<input
ref={fileRef}
type="file"
accept=".tar.gz,.tgz,application/gzip"
style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0]
if (f) void handleRestoreFile(f)
}}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={backupLoading || restoring}
style={{
padding: '10px 16px', borderRadius: 8,
border: '1px solid var(--warn)', background: 'transparent',
color: 'var(--warn)',
fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600,
cursor: restoring ? 'default' : 'pointer', minHeight: 44,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: restoring ? 0.6 : 1,
}}
>
<i className="fa-solid fa-upload" />
{restoring
? (restoreProgress < 100 ? `Upload ${restoreProgress}%…` : 'Restauration en cours…')
: 'Restaurer depuis une archive'}
</button>
{restoring && (
<div style={{ height: 6, background: 'var(--bg-4)', borderRadius: 999, overflow: 'hidden' }}>
<div style={{
width: `${restoreProgress}%`, height: '100%',
background: 'var(--warn)', transition: 'width 0.2s ease',
}} />
</div>
)}
{backupError && (
<div style={{ color: 'var(--err)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
{backupError}
</div>
)}
{backups.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
<div style={{ ...labelStyle, marginBottom: 0 }}>Sauvegardes disponibles</div>
{backups.map(b => (
<div
key={b.filename}
style={{
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px',
}}
>
<i className="fa-solid fa-file-zipper" style={{ color: 'var(--ink-3)', fontSize: 14, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.filename}
</div>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-4)' }}>
{formatDate(b.created_at)} {formatSize(b.size)}
</div>
</div>
<button
onClick={() => void handleRestore(b.filename)}
disabled={restoring === b.filename}
style={{
padding: '5px 10px', borderRadius: 6,
border: '1px solid var(--warn)', background: 'transparent',
color: 'var(--warn)', fontFamily: 'var(--font-ui)', fontSize: 11,
cursor: restoring === b.filename ? 'default' : 'pointer',
flexShrink: 0, opacity: restoring === b.filename ? 0.5 : 1,
}}
>
{restoring === b.filename ? '…' : 'Restaurer'}
</button>
</div>
))}
{backupInfo && (
<div style={{ color: 'var(--ok)', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 0' }}>
{backupInfo}
</div>
)}
{backups.length === 0 && !backupLoading && (
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>
Aucune sauvegarde disponible
</div>
)}
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 11, lineHeight: 1.4 }}>
L'archive contient le dump PostgreSQL et tout le dossier <code style={{ fontFamily: 'var(--font-mono)' }}>uploads/</code> (photos, audio, vidéos). À restaurer sur une instance compatible.
</div>
</div>
{/* Taille du texte */}
+408 -175
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useServerEvents } from '../hooks/useServerEvents'
import type { Note, NoteFilters } from '../api/notes'
import { fetchNotes, createNote, updateNote, deleteNote, addAttachment, deleteAttachment } from '../api/notes'
import Modal from '../components/Modal'
@@ -17,31 +18,204 @@ const inputStyle: React.CSSProperties = {
fontSize: 13,
}
const actionBtnStyle: React.CSSProperties = {
padding: '5px 10px',
borderRadius: 6,
border: '1px solid var(--bg-5)',
background: 'var(--bg-4)',
color: 'var(--ink-3)',
cursor: 'pointer',
fontSize: 13,
minHeight: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
}
function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt }: {
// Formate le texte inline : **gras**, *italique*, ~~barré~~, `code`, [lien](url)
function inlineFmt(text: string): React.ReactNode {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\))/)
return (
<>
{parts.map((p, i) => {
if (p.startsWith('**') && p.endsWith('**')) return <strong key={i}>{p.slice(2, -2)}</strong>
if (p.startsWith('~~') && p.endsWith('~~')) return <del key={i} style={{ color: 'var(--ink-4)' }}>{p.slice(2, -2)}</del>
if (p.startsWith('*') && p.endsWith('*')) return <em key={i} style={{ color: 'var(--ink-3)' }}>{p.slice(1, -1)}</em>
if (p.startsWith('`') && p.endsWith('`')) return <code key={i} style={{ background: 'var(--bg-4)', borderRadius: 3, padding: '0 4px', fontFamily: 'var(--font-mono)', fontSize: '0.88em' }}>{p.slice(1, -1)}</code>
const linkMatch = p.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (linkMatch) return <a key={i} href={linkMatch[2]} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)', textDecoration: 'underline' }}>{linkMatch[1]}</a>
return p || null
})}
</>
)
}
// Renderer pseudo-markdown ligne par ligne
function renderMarkdown(text: string): React.ReactNode {
const lines = text.split('\n')
const nodes: React.ReactNode[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// Blocs de code
if (line.startsWith('```')) {
const lang = line.slice(3).trim()
const code: string[] = []
i++
while (i < lines.length && !lines[i].startsWith('```')) { code.push(lines[i]); i++ }
nodes.push(
<pre key={i} style={{ background: 'var(--bg-4)', border: '1px solid var(--bg-5)', borderRadius: 6, padding: '8px 12px', margin: '6px 0', fontFamily: 'var(--font-mono)', fontSize: 12, overflowX: 'auto', color: 'var(--ink-1)', whiteSpace: 'pre' }}>
{lang && <div style={{ color: 'var(--ink-4)', fontSize: 10, marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.5 }}>{lang}</div>}
{code.join('\n')}
</pre>
)
// Tableaux : ligne commençant et finissant par |
} else if (line.startsWith('|') && line.trim().endsWith('|')) {
const tableLines: string[] = [line]
i++
while (i < lines.length && lines[i].startsWith('|') && lines[i].trim().endsWith('|')) {
tableLines.push(lines[i])
i++
}
const parseRow = (r: string) => r.split('|').slice(1, -1).map(c => c.trim())
const isSep = (r: string) => /^\|[\s|:-]+\|$/.test(r.trim())
const headers = parseRow(tableLines[0])
const bodyRows = tableLines.filter((_, idx) => idx > 0 && !isSep(tableLines[idx]))
nodes.push(
<div key={i} style={{ overflowX: 'auto', margin: '6px 0' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
<thead>
<tr>
{headers.map((h, hi) => (
<th key={hi} style={{ border: '1px solid var(--bg-5)', padding: '4px 8px', background: 'var(--bg-4)', color: 'var(--ink-2)', fontWeight: 600, textAlign: 'left' }}>{inlineFmt(h)}</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, ri) => (
<tr key={ri}>
{parseRow(row).map((cell, ci) => (
<td key={ci} style={{ border: '1px solid var(--bg-5)', padding: '4px 8px', color: 'var(--ink-2)' }}>{inlineFmt(cell)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
continue
// Titres
} else if (line.startsWith('# ')) {
nodes.push(<div key={i} style={{ fontWeight: 700, fontSize: 15, color: 'var(--accent)', marginTop: 10, marginBottom: 2, fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(2))}</div>)
} else if (line.startsWith('## ')) {
nodes.push(<div key={i} style={{ fontWeight: 700, fontSize: 14, color: 'var(--ink-1)', marginTop: 8, marginBottom: 2, paddingBottom: 3, borderBottom: '1px solid var(--bg-5)', fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(3))}</div>)
} else if (line.startsWith('### ')) {
nodes.push(<div key={i} style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink-1)', marginTop: 6, marginBottom: 1, fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(4))}</div>)
// Task lists : - [ ] et - [x]
} else if (/^(\s*)-\s\[([ xX])\]\s/.test(line)) {
const m = line.match(/^(\s*)-\s\[([ xX])\]\s(.*)/)
const indent = (m?.[1] ?? '').length
const done = (m?.[2] ?? ' ').toLowerCase() === 'x'
nodes.push(
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'flex-start', marginLeft: indent * 8, color: done ? 'var(--ink-4)' : 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<span style={{ flexShrink: 0, width: 14, height: 14, marginTop: 3, border: `1.5px solid ${done ? 'var(--ok)' : 'var(--ink-4)'}`, borderRadius: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', background: done ? 'var(--ok)' : 'transparent' }}>
{done && <i className="fa-solid fa-check" style={{ fontSize: 8, color: 'var(--bg-1)' }} />}
</span>
<span style={{ textDecoration: done ? 'line-through' : 'none' }}>{inlineFmt(m?.[3] ?? '')}</span>
</div>
)
// Listes non ordonnées (avec indentation possible)
} else if (/^(\s*)[-*]\s/.test(line)) {
const m = line.match(/^(\s*)[-*]\s(.*)/)
const indent = (m?.[1] ?? '').length
nodes.push(
<div key={i} style={{ display: 'flex', gap: 6, marginLeft: indent * 8, color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0 }}>{indent > 0 ? '◦' : '•'}</span>
<span>{inlineFmt(m?.[2] ?? '')}</span>
</div>
)
// Listes ordonnées
} else if (/^\d+\.\s/.test(line)) {
const m = line.match(/^(\d+)\.\s(.*)/)
nodes.push(
<div key={i} style={{ display: 'flex', gap: 6, color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0, minWidth: 18, textAlign: 'right' }}>{m?.[1]}.</span>
<span>{inlineFmt(m?.[2] ?? '')}</span>
</div>
)
// Citation
} else if (line.startsWith('> ')) {
nodes.push(
<div key={i} style={{ borderLeft: '3px solid var(--accent)', paddingLeft: 10, color: 'var(--ink-3)', fontStyle: 'italic', margin: '4px 0', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
{inlineFmt(line.slice(2))}
</div>
)
// Séparateur
} else if (line === '---' || line === '***') {
nodes.push(<div key={i} style={{ borderBottom: '1px solid var(--bg-5)', margin: '8px 0' }} />)
// Ligne vide
} else if (line.trim() === '') {
nodes.push(<div key={i} style={{ height: 5 }} />)
// Paragraphe normal
} else {
nodes.push(
<div key={i} style={{ color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
{inlineFmt(line)}
</div>
)
}
i++
}
return <>{nodes}</>
}
type NoteState = 'semi' | 'expanded' | 'collapsed'
function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, onDeleteAtt }: {
note: Note
onEdit: () => void
onDelete: () => void
onAddPhoto: (file: File) => void
onAddAudio: (file: File) => void
onAddVideo: (file: File) => void
onDeleteAtt: (attId: string) => void
}) {
const [state, setState] = useState<NoteState>('semi')
const photoRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLInputElement>(null)
const [recording, setRecording] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([])
const images = note.attachments.filter(a => a.file_type === 'image')
const audios = note.attachments.filter(a => a.file_type === 'audio')
const videos = note.attachments.filter(a => a.file_type === 'video')
function cycleState() {
setState(s => s === 'semi' ? 'expanded' : s === 'expanded' ? 'collapsed' : 'semi')
}
const stateIcon = state === 'semi' ? 'fa-chevron-down' : state === 'expanded' ? 'fa-minus' : 'fa-chevron-right'
const stateTitle = state === 'semi' ? 'Tout afficher' : state === 'expanded' ? 'Réduire' : 'Développer'
async function startRecord() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// Choisir le format supporté par le navigateur (Safari → mp4, Chrome/Firefox → webm)
const mimeType = ['audio/webm', 'audio/mp4', 'audio/ogg'].find(t => MediaRecorder.isTypeSupported(t)) ?? ''
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
chunksRef.current = []
@@ -56,9 +230,7 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
recorder.start()
recorderRef.current = recorder
setRecording(true)
} catch {
// micro non disponible ou permission refusée
}
} catch { /* micro non disponible */ }
}
function stopRecord() {
@@ -66,98 +238,196 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
setRecording(false)
}
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* En-tête */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
<div style={{ flex: 1, minWidth: 0 }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 2 }}>
{note.title}
</div>
)}
<div style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{note.content.length > 200 ? note.content.slice(0, 200) + '…' : note.content}
</div>
</div>
</div>
const toggleBtn = (
<button
onClick={cycleState}
title={stateTitle}
style={{ background: 'transparent', border: 'none', color: 'var(--ink-4)', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, fontSize: 13, flexShrink: 0, ...noSelect }}
>
<i className={`fa-solid ${stateIcon}`} />
</button>
)
{/* Photos */}
const metaLine = (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}>
<span style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatDate(note.created_at)}</span>
{note.category && <span style={{ background: 'var(--bg-4)', color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{note.category}</span>}
{note.tags.map(t => <span key={t} style={{ background: 'var(--bg-5)', color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{t}</span>)}
{images.length > 0 && <i className="fa-solid fa-image" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${images.length} photo(s)`} />}
{audios.length > 0 && <i className="fa-solid fa-microphone" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${audios.length} audio(s)`} />}
{videos.length > 0 && <i className="fa-solid fa-video" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${videos.length} vidéo(s)`} />}
{note.urls.length > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--info)', fontSize: 11 }} title={`${note.urls.length} lien(s)`}>
<i className="fa-solid fa-link" style={{ fontSize: 10 }} />
{note.urls.length}
</span>
)}
{note.gps_lat != null && (
<i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />
)}
</div>
)
const urlsSection = note.urls.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{note.urls.map((u, i) => (
<a
key={i}
href={u.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', alignItems: 'center', gap: 8,
background: 'var(--bg-4)', borderRadius: 8, padding: '6px 10px',
textDecoration: 'none', overflow: 'hidden',
}}
onClick={e => e.stopPropagation()}
>
<i className="fa-solid fa-arrow-up-right-from-square" style={{ color: 'var(--info)', fontSize: 11, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.label}
</div>
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.url}
</div>
</div>
</a>
))}
</div>
) : null
const mediaSection = (
<>
{images.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{images.map(img => (
<div key={img.id} style={{ position: 'relative' }}>
<img
src={`/media/${img.thumbnail_path ?? img.file_path}`}
alt=""
style={{ width: 72, height: 72, objectFit: 'cover', borderRadius: 6 }}
/>
<button
onClick={() => onDeleteAtt(img.id)}
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.6)', border: 'none', color: '#fff', borderRadius: '50%', width: 18, height: 18, cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
></button>
<img src={`/media/${img.thumbnail_path ?? img.file_path}`} alt="" style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 6 }} />
<button onClick={() => onDeleteAtt(img.id)} style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 20, height: 20, cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}></button>
</div>
))}
</div>
)}
{/* Audios */}
{audios.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{audios.map(aud => (
<div key={aud.id} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'var(--bg-4)', borderRadius: 6, padding: '4px 8px' }}>
<audio src={`/media/${aud.file_path}`} controls style={{ height: 28, flex: 1 }} />
<button
onClick={() => onDeleteAtt(aud.id)}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0 }}
></button>
<div key={aud.id} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'var(--bg-4)', borderRadius: 6, padding: '4px 8px', minWidth: 0 }}>
<audio
src={`/media/${aud.file_path}`}
controls
style={{ height: 32, flex: 1, minWidth: 0, width: '100%' }}
onLoadedMetadata={e => { (e.target as HTMLAudioElement).volume = 0.5 }}
/>
<button onClick={() => onDeleteAtt(aud.id)} style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontSize: 14, flexShrink: 0 }}></button>
</div>
))}
</div>
)}
{videos.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{videos.map(vid => (
<div key={vid.id} style={{ position: 'relative' }}>
<video src={`/media/${vid.file_path}`} controls playsInline style={{ width: '100%', maxHeight: 220, borderRadius: 6, background: '#000', display: 'block' }} />
<button onClick={() => onDeleteAtt(vid.id)} style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 22, height: 22, cursor: 'pointer', fontSize: 11, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}></button>
</div>
))}
</div>
)}
</>
)
{/* Méta */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}>
<span style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatDate(note.created_at)}</span>
{note.category && (
<span style={{ background: 'var(--bg-4)', color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{note.category}</span>
)}
{note.tags.map(t => (
<span key={t} style={{ background: 'var(--bg-5)', color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{t}</span>
))}
{note.gps_lat != null && <i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat?.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />}
const actionButtons = (
<div style={{ display: 'flex', gap: 6 }}>
<input ref={photoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddPhoto(f); e.target.value = '' }} />
<button onClick={() => photoRef.current?.click()} title="Photo" style={actionBtnStyle}><i className="fa-solid fa-camera" /></button>
<input ref={audioRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} />
<button
onClick={recording ? stopRecord : startRecord}
title={recording ? "Arrêter" : "Enregistrer"}
style={{ ...actionBtnStyle, background: recording ? 'var(--err)' : actionBtnStyle.background, color: recording ? '#fff' : actionBtnStyle.color, border: recording ? 'none' : actionBtnStyle.border }}
><i className={`fa-solid fa-${recording ? 'stop' : 'microphone'}`} /></button>
<input ref={videoRef} type="file" accept="video/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} />
<button onClick={() => videoRef.current?.click()} title="Vidéo" style={actionBtnStyle}><i className="fa-solid fa-video" /></button>
<div style={{ flex: 1 }} />
<button onClick={onEdit} title="Éditer" style={{ ...actionBtnStyle, background: 'var(--bg-5)', border: 'none' }}><i className="fa-solid fa-pen" /></button>
<button onClick={onDelete} title="Supprimer" style={{ ...actionBtnStyle, background: 'transparent', color: 'var(--err)' }}><i className="fa-solid fa-xmark" /></button>
</div>
)
// ─── COLLAPSED ───────────────────────────────────────────────────────────────
if (state === 'collapsed') {
return (
<div className="glass" style={{ borderRadius: 10, padding: '8px 14px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
<span style={{ color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: note.title ? 600 : 400, overflowWrap: 'anywhere' }}>
{note.title || note.content.slice(0, 60).replace(/\n/g, ' ')}
</span>
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
{formatDate(note.created_at)}
</span>
</div>
{toggleBtn}
</div>
</div>
)
}
{/* Actions */}
<div style={{ display: 'flex', gap: 6 }}>
<input ref={photoRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddPhoto(f); e.target.value = '' }} />
<button
onClick={() => photoRef.current?.click()}
title="Ajouter une photo"
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
>📷</button>
<input ref={audioRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} />
<button
onClick={recording ? stopRecord : startRecord}
title={recording ? 'Arrêter l\'enregistrement' : 'Enregistrer un audio'}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: recording ? 'var(--err)' : 'var(--bg-4)', color: recording ? '#fff' : 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
>{recording ? '⏹ Stop' : '🎤'}</button>
<div style={{ flex: 1 }} />
<button
onClick={onEdit}
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'var(--bg-5)', color: 'var(--ink-2)', cursor: 'pointer', fontSize: 13 }}
></button>
<button
onClick={onDelete}
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
></button>
// ─── SEMI (défaut) ───────────────────────────────────────────────────────────
if (state === 'semi') {
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', ...noSelect }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, marginBottom: 4, overflowWrap: 'anywhere' }}>
{note.title}
</div>
)}
<div style={{
color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
overflowWrap: 'anywhere',
} as React.CSSProperties}>
{note.content}
</div>
</div>
{toggleBtn}
</div>
{metaLine}
{actionButtons}
</div>
)
}
// ─── EXPANDED ────────────────────────────────────────────────────────────────
return (
<div className="glass" style={{ borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, ...noSelect }}>
<div style={{ flex: 1, minWidth: 0 }}>
{note.title && (
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15, overflowWrap: 'anywhere' }}>
{note.title}
</div>
)}
</div>
{toggleBtn}
</div>
<div style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{renderMarkdown(note.content)}</div>
{urlsSection}
{mediaSection}
{metaLine}
{actionButtons}
</div>
)
}
// ─── PAGE ─────────────────────────────────────────────────────────────────────
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
@@ -174,13 +444,7 @@ export default function NotesPage() {
<button
onClick={() => setShowForm(true)}
aria-label="Nouvelle note"
style={{
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 24, cursor: 'pointer',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
style={{ width: 56, height: 56, borderRadius: '50%', background: 'var(--accent)', color: '#1d2021', border: 'none', fontSize: 24, cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>+</button>
)
return () => setActionButton(null)
@@ -199,6 +463,7 @@ export default function NotesPage() {
}, [filters])
useEffect(() => { void load() }, [load])
useServerEvents({ notes_changed: () => void load() })
function handleSearchChange(val: string) {
setSearchInput(val)
@@ -222,53 +487,64 @@ export default function NotesPage() {
async function handleDelete(id: string) {
if (!confirm('Supprimer cette note ?')) return
try {
await deleteNote(id)
void load()
} catch {
setError('Erreur lors de la suppression')
}
try { await deleteNote(id); void load() }
catch { setError('Erreur lors de la suppression') }
}
async function handleAddPhoto(noteId: string, file: File) {
try {
await addAttachment(noteId, file)
void load()
} catch {
setError('Erreur upload photo')
}
try { await addAttachment(noteId, file); void load() }
catch { setError('Erreur upload photo') }
}
async function handleAddAudio(noteId: string, file: File) {
try {
await addAttachment(noteId, file)
void load()
} catch {
setError('Erreur upload audio')
}
try { await addAttachment(noteId, file); void load() }
catch { setError('Erreur upload audio') }
}
async function handleAddVideo(noteId: string, file: File) {
try { await addAttachment(noteId, file); void load() }
catch { setError('Erreur upload vidéo') }
}
async function handleDeleteAtt(noteId: string, attId: string) {
try {
await deleteAttachment(noteId, attId)
void load()
} catch {
setError('Erreur suppression pièce jointe')
}
try { await deleteAttachment(noteId, attId); void load() }
catch { setError('Erreur suppression pièce jointe') }
}
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_gps
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_video || filters.has_gps
const noteGrid = (cols: string) => (
<div style={{ display: 'grid', gridTemplateColumns: cols, gap: 10, alignItems: 'start' }}>
{notes.map(note => (
<NoteCard
key={note.id}
note={note}
onEdit={() => setEditingNote(note)}
onDelete={() => void handleDelete(note.id)}
onAddPhoto={f => void handleAddPhoto(note.id, f)}
onAddAudio={f => void handleAddAudio(note.id, f)}
onAddVideo={f => void handleAddVideo(note.id, f)}
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
/>
))}
</div>
)
return (
<div className="p-4">
{/* En-tête */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, minWidth: 100, ...noSelect }}>
Notes
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, ...noSelect }}>Notes</h1>
{/* Bouton ajout visible sur laptop uniquement */}
<button
className="hidden lg:flex"
onClick={() => setShowForm(true)}
style={{ alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, cursor: 'pointer', ...noSelect }}
>
<i className="fa-solid fa-plus" /> Nouvelle note
</button>
</div>
{/* Barre de recherche + filtres */}
{/* Barre recherche + filtres */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<input
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
@@ -276,101 +552,58 @@ export default function NotesPage() {
value={searchInput}
onChange={e => handleSearchChange(e.target.value)}
/>
{/* Filtres rapides */}
{(['📷 Photo', '🎤 Audio', '📍 GPS'] as const).map((label, i) => {
const key = ['has_photo', 'has_audio', 'has_gps'][i] as keyof NoteFilters
{([
{ key: 'has_photo', icon: 'fa-image', label: 'Photo' },
{ key: 'has_audio', icon: 'fa-microphone', label: 'Audio' },
{ key: 'has_video', icon: 'fa-video', label: 'Vidéo' },
{ key: 'has_gps', icon: 'fa-location-dot', label: 'GPS' },
] as const).map(({ key, icon, label }) => {
const active = filters[key] === true
return (
<button
key={label}
key={key}
onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))}
style={{
padding: '5px 12px', borderRadius: 999, border: 'none',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? '#1d2021' : 'var(--ink-3)',
cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12,
...noSelect,
}}
>{label}</button>
style={{ padding: '5px 10px', borderRadius: 999, border: 'none', background: active ? 'var(--accent)' : 'var(--bg-3)', color: active ? '#1d2021' : 'var(--ink-3)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 5, ...noSelect }}
>
<i className={`fa-solid ${icon}`} style={{ fontSize: 11 }} />{label}
</button>
)
})}
{hasActiveFilters && (
<button
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_gps: undefined }))}
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_video: undefined, has_gps: undefined }))}
style={{ padding: '5px 10px', borderRadius: 999, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 12, ...noSelect }}
> Filtres</button>
><i className="fa-solid fa-xmark" /> Filtres</button>
)}
</div>
{error && (
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
{error}
</p>
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontFamily: 'var(--font-ui)', fontSize: 13 }}>{error}</p>
)}
{/* Modal création */}
{showForm && (
<Modal title="Nouvelle note" onClose={() => setShowForm(false)}>
<NoteForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
</Modal>
)}
{/* Modal édition */}
{editingNote && (
<Modal title="Modifier la note" onClose={() => setEditingNote(null)}>
<NoteForm
initialValues={editingNote}
onSubmit={data => handleUpdate(editingNote.id, data)}
onCancel={() => setEditingNote(null)}
submitLabel="Enregistrer"
/>
<NoteForm initialValues={editingNote} onSubmit={data => handleUpdate(editingNote.id, data)} onCancel={() => setEditingNote(null)} submitLabel="Enregistrer" />
</Modal>
)}
{loading && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24, ...noSelect }}>Chargement</p>
{loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24, ...noSelect }}>Chargement</p>}
{!loading && notes.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
)}
{/* Mobile — liste chronologique */}
<div className="block lg:hidden">
{!loading && notes.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{notes.map(note => (
<NoteCard
key={note.id}
note={note}
onEdit={() => setEditingNote(note)}
onDelete={() => void handleDelete(note.id)}
onAddPhoto={f => void handleAddPhoto(note.id, f)}
onAddAudio={f => void handleAddAudio(note.id, f)}
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
/>
))}
</div>
{noteGrid('1fr')}
</div>
{/* Laptop — grille */}
<div className="hidden lg:block">
{!loading && notes.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40, ...noSelect }}>Aucune note</p>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
{notes.map(note => (
<NoteCard
key={note.id}
note={note}
onEdit={() => setEditingNote(note)}
onDelete={() => void handleDelete(note.id)}
onAddPhoto={f => void handleAddPhoto(note.id, f)}
onAddAudio={f => void handleAddAudio(note.id, f)}
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
/>
))}
</div>
{noteGrid('repeat(3, 1fr)')}
</div>
</div>
)
}
+363 -25
View File
@@ -1,15 +1,17 @@
// frontend/src/pages/ShoppingPage.tsx
import { useState, useEffect, useCallback, useRef } from 'react'
import { useServerEvents } from '../hooks/useServerEvents'
import { matchesSearch } from '../utils/search'
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
import {
fetchLists, createList, fetchListDetail, deleteList,
fetchLists, createList, createProjectList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
searchProducts, createProduct,
} from '../api/shopping'
import Modal from '../components/Modal'
import BottomSheet from '../components/BottomSheet'
import ItemRow from '../components/shopping/ItemRow'
import ProjectItemCard from '../components/shopping/ProjectItemCard'
import CatalogueModal from '../components/shopping/CatalogueModal'
import BoutiquesModal from '../components/shopping/BoutiquesModal'
import { useWakeLock } from '../hooks/useWakeLock'
@@ -29,6 +31,24 @@ const inputStyle: React.CSSProperties = {
const noSelect: React.CSSProperties = { userSelect: 'none' }
function isoWeek(d: Date): { week: number; year: number } {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7))
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
return { week, year: date.getUTCFullYear() }
}
function isListOutdated(name: string | null | undefined): boolean {
if (!name) return false
const m = /^S(\d{1,2})\s+(\d{4})$/.exec(name)
if (!m) return false
const listWeek = parseInt(m[1], 10)
const listYear = parseInt(m[2], 10)
const { week, year } = isoWeek(new Date())
return year > listYear || (year === listYear && week > listWeek)
}
function QtyControls({ qty, onDecrement, onIncrement }: { qty: number; onDecrement: () => void; onIncrement: () => void }) {
const btnBase: React.CSSProperties = {
width: 32, height: 32, borderRadius: 8, border: 'none',
@@ -113,7 +133,8 @@ export default function ShoppingPage() {
setStores(storesData)
setProducts([...productsData].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
const current = listsData.find(l => l.status === 'draft' || l.status === 'active')
const current = listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'weekly')
?? listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
if (current) {
setCurrentList(await fetchListDetail(current.id))
} else {
@@ -127,6 +148,7 @@ export default function ShoppingPage() {
}, [])
useEffect(() => { void loadData() }, [loadData])
useServerEvents({ shopping_changed: () => void loadData() })
async function refreshProducts() {
try {
@@ -332,10 +354,88 @@ export default function ShoppingPage() {
}
}
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
const [showNewProjectModal, setShowNewProjectModal] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [newProjectStoreId, setNewProjectStoreId] = useState('')
const [projectCreating, setProjectCreating] = useState(false)
const [showProjectItemModal, setShowProjectItemModal] = useState(false)
const [editingProjectItem, setEditingProjectItem] = useState<ShoppingItem | null>(null)
const [projItemName, setProjItemName] = useState('')
const [projItemDesc, setProjItemDesc] = useState('')
const [projItemUrl, setProjItemUrl] = useState('')
const [projItemImageUrl, setProjItemImageUrl] = useState('')
const [projItemStoreId, setProjItemStoreId] = useState('')
const [projItemSaving, setProjItemSaving] = useState(false)
async function handleCreateProject() {
if (!newProjectName.trim()) return
setProjectCreating(true)
try {
const detail = await createProjectList(newProjectName.trim(), newProjectStoreId || undefined)
setCurrentList(detail)
setShowNewProjectModal(false)
setNewProjectName('')
setNewProjectStoreId('')
void loadData()
} catch {
setError('Erreur lors de la création')
} finally {
setProjectCreating(false)
}
}
function openProjectItemModal(item?: ShoppingItem) {
if (item) {
setEditingProjectItem(item)
setProjItemName(item.display_name)
setProjItemDesc(item.description ?? '')
setProjItemUrl(item.url ?? '')
setProjItemImageUrl(item.image_url ?? '')
setProjItemStoreId('')
} else {
setEditingProjectItem(null)
setProjItemName('')
setProjItemDesc('')
setProjItemUrl('')
setProjItemImageUrl('')
setProjItemStoreId('')
}
setShowProjectItemModal(true)
}
async function handleSaveProjectItem() {
if (!currentList || !projItemName.trim()) return
setProjItemSaving(true)
try {
if (editingProjectItem) {
await updateItem(currentList.id, editingProjectItem.id, {
url: projItemUrl.trim() || undefined,
description: projItemDesc.trim() || undefined,
image_url: projItemImageUrl.trim() || undefined,
})
} else {
await addItem(currentList.id, {
custom_name: projItemName.trim(),
description: projItemDesc.trim() || undefined,
url: projItemUrl.trim() || undefined,
image_url: projItemImageUrl.trim() || undefined,
})
}
setShowProjectItemModal(false)
void refreshCurrentList()
} catch {
setError("Erreur lors de l'enregistrement")
} finally {
setProjItemSaving(false)
}
}
async function handleFinish() {
if (!currentList) return
try {
await finishShopping(currentList.id)
setShowFinishConfirm(false)
void loadData()
} catch {
setError('Erreur lors de la finalisation')
@@ -374,7 +474,9 @@ export default function ShoppingPage() {
const uncheckedItems = sortedItems.filter(i => !i.is_checked)
const checkedItems = sortedItems.filter(i => i.is_checked)
const hasCurrentList = currentList !== null
const pastLists = allLists.filter(l => l.status === 'done')
const isProjectList = currentList?.list_type === 'project'
const pastLists = allLists.filter(l => l.status === 'done' && l.list_type === 'weekly')
const activeProjectLists = allLists.filter(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
const filteredProducts = products.filter(p => {
const term = itemSearch.trim()
@@ -395,8 +497,13 @@ export default function ShoppingPage() {
borderBottom: '1px solid var(--bg-4)',
position: 'sticky', top: 0, zIndex: 10,
}}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect, display: 'flex', alignItems: 'center', gap: 8 }}>
{hasCurrentList ? (currentList.name ?? 'Courses') : 'Courses'}
{isProjectList && (
<span style={{ fontSize: 10, background: 'var(--info)', color: '#fff', borderRadius: 999, padding: '2px 7px', fontFamily: 'var(--font-ui)', fontWeight: 600, letterSpacing: 0.3 }}>
PROJET
</span>
)}
</h1>
<button
onClick={() => setShowCatalogueModal(true)}
@@ -416,6 +523,36 @@ export default function ShoppingPage() {
...noSelect,
}}
>Boutiques</button>
{hasCurrentList && !isProjectList && (
<button
className="hidden lg:flex"
onClick={openAddSheet}
style={{
alignItems: 'center', gap: 8,
background: 'var(--accent)', border: 'none',
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
...noSelect,
}}
>
<i className="fa-solid fa-cart-plus" /> Article
</button>
)}
{hasCurrentList && isProjectList && (
<button
className="hidden lg:flex"
onClick={() => openProjectItemModal()}
style={{
alignItems: 'center', gap: 8,
background: 'var(--accent)', border: 'none',
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
...noSelect,
}}
>
<i className="fa-solid fa-plus" /> Ajouter
</button>
)}
</div>
{/* ── Erreur ── */}
@@ -482,6 +619,38 @@ export default function ShoppingPage() {
}}
>Voir l'historique ({pastLists.length})</button>
)}
{/* Séparateur + listes projet */}
<div style={{ width: '100%', borderTop: '1px solid var(--bg-4)', paddingTop: 8 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.5, ...noSelect }}>
Listes projet
</span>
<button
onClick={() => setShowNewProjectModal(true)}
style={{ background: 'var(--bg-3)', border: '1px solid var(--bg-5)', borderRadius: 8, color: 'var(--ink-2)', cursor: 'pointer', padding: '4px 10px', fontFamily: 'var(--font-ui)', fontSize: 12, ...noSelect }}
>+ Nouveau projet</button>
</div>
{activeProjectLists.length === 0 && (
<p style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 13, margin: 0, textAlign: 'center', ...noSelect }}>Aucun projet en cours</p>
)}
{activeProjectLists.map(list => (
<div
key={list.id}
onClick={() => void fetchListDetail(list.id).then(d => { setCurrentList(d) })}
className="glass interactive"
style={{ borderRadius: 8, padding: '10px 14px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}
>
<i className="fa-solid fa-bag-shopping" style={{ color: 'var(--info)', fontSize: 14, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, ...noSelect }}>{list.name}</div>
<div style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)', ...noSelect }}>{list.item_count} article{list.item_count > 1 ? 's' : ''}</div>
</div>
<span style={{ color: 'var(--ink-3)', fontSize: 16 }}></span>
</div>
))}
</div>
</div>
)}
@@ -494,10 +663,27 @@ export default function ShoppingPage() {
padding: '8px 16px',
background: 'var(--bg-3)',
borderBottom: '1px solid var(--bg-4)',
flexWrap: 'wrap',
}}>
<span style={{ flex: 1, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
{checkedItems.length}/{currentList.item_count} cochés
</span>
{!isProjectList && isListOutdated(currentList.name) && (
<span
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
style={{
background: 'var(--warn)', color: '#1d2021',
borderRadius: 999, padding: '2px 8px',
fontFamily: 'var(--font-ui)', fontSize: 11, fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4,
...noSelect,
}}
>
<i className="fa-solid fa-triangle-exclamation" style={{ fontSize: 10 }} />
semaine dépassée
</span>
)}
<div style={{ flex: 1 }} />
{pastLists.length > 0 && (
<button
onClick={() => setShowHistoryModal(true)}
@@ -508,15 +694,35 @@ export default function ShoppingPage() {
onClick={() => void handleDeleteCurrentList()}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Supprimer</button>
<button
onClick={() => void handleFinish()}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
}}
>Terminer </button>
{!isProjectList && (
<button
onClick={() => setShowFinishConfirm(true)}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>
<i className="fa-solid fa-check" /> Clôturer la semaine
</button>
)}
{isProjectList && (
<button
onClick={() => openProjectItemModal()}
className="flex lg:hidden"
style={{
background: 'var(--accent)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
alignItems: 'center', gap: 6,
}}
>
<i className="fa-solid fa-plus" /> Ajouter
</button>
)}
</div>
{/* Articles non cochés */}
@@ -526,17 +732,32 @@ export default function ShoppingPage() {
</p>
)}
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64 }}>
{uncheckedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode
/>
))}
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64, padding: isProjectList ? '12px 16px 64px' : '0 0 64px' }}>
{isProjectList ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{sortedItems.map(item => (
<ProjectItemCard
key={item.id}
item={item}
stores={stores}
onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openProjectItemModal(item)}
/>
))}
</div>
) : (
uncheckedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode
/>
))
)}
{checkedItems.length > 0 && (
<>
@@ -799,6 +1020,123 @@ export default function ShoppingPage() {
onStoresChanged={() => void loadData()}
/>
)}
{showFinishConfirm && currentList && (
<Modal title="Clôturer la semaine ?" onClose={() => setShowFinishConfirm(false)} width={420}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p style={{ margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)', fontSize: 14, lineHeight: 1.5 }}>
La liste <strong style={{ color: 'var(--ink-1)' }}>{currentList.name ?? 'en cours'}</strong> va être archivée.
</p>
{uncheckedItems.length > 0 ? (
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13, lineHeight: 1.5 }}>
<strong style={{ color: 'var(--warn)' }}>{uncheckedItems.length}</strong> article{uncheckedItems.length > 1 ? 's' : ''} non coché{uncheckedItems.length > 1 ? 's' : ''} {uncheckedItems.length > 1 ? 'seront reportés' : 'sera reporté'} dans la nouvelle liste de la semaine en cours.
</p>
) : (
<p style={{ margin: 0, color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Tous les articles sont cochés. Une nouvelle liste vide sera créée pour la semaine en cours.
</p>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowFinishConfirm(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleFinish()}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--ok)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44 }}
>Clôturer</button>
</div>
</div>
</Modal>
)}
{/* Modale création liste projet */}
{showNewProjectModal && (
<Modal title="Nouveau projet d'achat" onClose={() => setShowNewProjectModal(false)} width={420}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input
style={inputStyle}
placeholder="Nom du projet (ex: RAM pour PC)"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleCreateProject()}
/>
<select
style={inputStyle}
value={newProjectStoreId}
onChange={e => setNewProjectStoreId(e.target.value)}
>
<option value="">Boutique (optionnel)</option>
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowNewProjectModal(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleCreateProject()}
disabled={!newProjectName.trim() || projectCreating}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44, opacity: projectCreating ? 0.7 : 1 }}
>{projectCreating ? '…' : 'Créer'}</button>
</div>
</div>
</Modal>
)}
{/* Modale ajout/édition item projet */}
{showProjectItemModal && (
<Modal
title={editingProjectItem ? `Modifier — ${editingProjectItem.display_name}` : 'Ajouter un article'}
onClose={() => setShowProjectItemModal(false)}
width={480}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{!editingProjectItem && (
<input
style={inputStyle}
placeholder="Nom de l'article *"
value={projItemName}
onChange={e => setProjItemName(e.target.value)}
autoFocus
/>
)}
<textarea
style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }}
placeholder="Description (optionnel)"
value={projItemDesc}
onChange={e => setProjItemDesc(e.target.value)}
autoFocus={!!editingProjectItem}
/>
<input
style={inputStyle}
placeholder="Lien URL (ex: https://amazon.fr/...)"
value={projItemUrl}
onChange={e => setProjItemUrl(e.target.value)}
type="url"
/>
<input
style={inputStyle}
placeholder="Image URL (ex: https://.../.jpg)"
value={projItemImageUrl}
onChange={e => setProjItemImageUrl(e.target.value)}
type="url"
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
<button
onClick={() => setShowProjectItemModal(false)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
>Annuler</button>
<button
onClick={() => void handleSaveProjectItem()}
disabled={(!editingProjectItem && !projItemName.trim()) || projItemSaving}
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44, opacity: projItemSaving ? 0.7 : 1 }}
>{projItemSaving ? '…' : 'Enregistrer'}</button>
</div>
</div>
</Modal>
)}
</div>
)
}
+9
View File
@@ -1,5 +1,6 @@
// frontend/src/pages/TodosPage.tsx
import { useState, useEffect, useCallback } from 'react'
import { useServerEvents } from '../hooks/useServerEvents'
import type { Todo, TodoCreate, TodoFilters } from '../api/todos'
import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '../api/todos'
import SwipeableRow from '../components/todos/SwipeableRow'
@@ -73,6 +74,7 @@ export default function TodosPage() {
}, [filters])
useEffect(() => { void load() }, [load])
useServerEvents({ todos_changed: () => void load() })
async function handleCreate(data: TodoCreate) {
try {
@@ -147,6 +149,13 @@ export default function TodosPage() {
<option value="cancelled">Annulé</option>
<option value="">Tous</option>
</select>
<button
className="hidden lg:flex"
onClick={() => setShowForm(true)}
style={{ alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, cursor: 'pointer', ...noSelect }}
>
<i className="fa-solid fa-plus" /> Nouvelle tâche
</button>
</div>
{error && (