Compare commits

...
4 Commits
Author SHA1 Message Date
bruno 9fb094f505 feat: refonte mobile de la section Configurations #114
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 4m50s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 15m34s
2026-09-24 08:43:18 -04:00
bruno d142049216 fix: degagement mobile sous la barre de navigation du bas (clearance retargetee .main-body) BUG-073
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 1m21s
CI / e2e (push) Successful in 14m0s
2026-09-23 23:36:35 -04:00
bruno 33fe1a3439 feat: ordre naturel des sections Configurations et avatar utilisateur #113
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m28s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 13m43s
2026-09-23 22:18:29 -04:00
bruno a726ad8511 feat: en-tete allege, compte utilisateur en sidebar et pellicule d'images defilable (molette + fleches) #112
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m29s
CI / test (push) Successful in 4m59s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 13m49s
2026-09-23 21:06:41 -04:00
36 changed files with 2645 additions and 410 deletions
+3
View File
@@ -38,9 +38,12 @@ jobs:
- name: Frontend unit tests
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/image-viewer.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
run: |
+98 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.20.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.23.0**.
---
@@ -14,6 +14,103 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.23.0] — 2026-09-24
### Ajouté
- **#114 — Configuration, refonte mobile-responsive.** La page « Configurations »
est désormais pensée pour le tactile (≤ 768 px) : modale **plein écran**
(`100dvh`, safe-area), sommaire en **drawer coulissant** gauche
(`position: fixed`, largeur `min(320px, 88vw)`) avec fond assombri
(`#config-modal.config-toc-open::before`) et bouton de fermeture
`#config-toc-close` (tap sur le backdrop ou Échap ferme le drawer d'abord,
puis la modale) ; formulaires sur **une colonne** ; tous les boutons
(save/secondary/danger/add/sm/hamburger/fermer) et liens du sommaire en cibles
**≥ 44 px** ; champs et selects en **16 px + min-height 44 px** (anti-zoom
iOS) ; rangée « Sauvegarder / Réindexer / Réinitialiser » **sticky** en bas de
sa section avec safe-area ; MFA (codes de secours en 1 colonne, champ de code
full-width et wrap des rangées d'action) ; débordements webhook/token/share
corrigés. Dettes HTML/i18n de l'audit incluses : `.config-actions-row` replacée
dans `#cfg-backend-settings` (+ `</section>` orphelin supprimé), id dupliqué
`cfg-partages-publics` retiré du `<h2>`, conteneur mort
`#plugins-settings-container` supprimé, libellé `#mt-explorer` i18n
(`settings.explorer`), doublons `.config-btn-add` et règle morte
`.mfa-recovery-input` purgés ; i18n : clés mortes `settings.backend`,
`settings.backend_hint`, `settings.restart_badge`, `settings.save`,
`settings.plugins` supprimées, `config.toc_close` ajoutée, `settings.tabs` FR
corrigé (« Onglets »). Tests : `config-mobile.test.mjs` étendu (27, au CI) +
E2E `config-mobile.spec.js` (5, projet `chromium-mobile`). Fiche :
[docs/features/settings-mobile-114.md](./docs/features/settings-mobile-114.md).
---
## [2.22.1] — 2026-09-23
### Corrigé
- **BUG-073 — mobile, fin de contenu masquée.** La barre de navigation fixe du bas
(`#mobile-toolbar`, 64 px + safe-area) recouvrait le bas de tous les documents et
pages en vue mobile (≤ 768 px) : les dernières lignes restaient définitivement sous
la barre. Cause : la règle de clearance du bloc mobile ciblait `.main-layout`,
classe absente de `index.html` (le vrai conteneur est `.main-body`) — sélecteur
mort, aucun dégagement réservé. Correctif : cible `.main-body`
(`padding-bottom: calc(64px + env(safe-area-inset-bottom, 0))`), suppression de la
règle sœur morte `.editor-modal.active ~ .main-layout`, reset du dégagement en mode
lecture (barre masquée) et `body.np-active .content-area` ramené à `76 px` (le
dégagement dock, sans double comptage avec `.main-body`). Tests :
`tests/frontend/mobile-toolbar.test.mjs` (7, au CI) et E2E
`tests/e2e/mobile-toolbar.spec.js` (2, projet `chromium-mobile`).
---
## [2.22.0] — 2026-09-23
### Ajouté
- **#113 — avatar utilisateur.** La section « Profil » des Configurations permet de
**choisir / importer une image** (PNG, JPG, WEBP, 8 Mo max) : elle est recadrée en
carré 256 px côté client, validée côté serveur (data-URL + octets magiques, sans SVG)
et persistée sur le compte (`avatar` dans `data/users.json`, exposé par
`GET/PATCH /api/auth/me` et la réponse de login). L'image s'affiche dans le **cercle
du profil en bas de la sidebar** (initiales en repli) et peut être supprimée. UI :
aperçu circulaire avec overlay caméra au survol, boutons Choisir / Supprimer, erreurs
en ligne. Clés i18n FR/EN.
### Modifié
- **#113 — ordre des sections Configurations.** La TOC et la page sont réorganisées
dans un ordre naturel et **parfaitement synchronisées** : **Profil en premier**,
puis Sécurité, Thèmes, Recherche, Historique récent, Tags, Fichiers cachés,
Synchronisation, Backend, Diagnostics, IA, Sources connectées, Clés API & MCP,
Push, Webhooks, Partages publics, Plugins et **À propos en dernier**. Les ancres
`cfg-tags` et `cfg-partages-publics` sont désormais portées par leur `<section>`
(cible de défilement = haut de section). Garde-fous : test statique
`tests/frontend/settings-order-avatar.test.mjs` (ordre TOC = page, pas d'ancre morte).
---
## [2.21.0] — 2026-09-23
### Ajouté
- **#112 — section compte en bas de la sidebar.** L'identité (avatar avec
initiales, nom, rôle) et la **déconnexion** sont regroupées dans un bloc
épinglé au bas de la barre latérale, comme sur les sites professionnels ; un
clic sur l'identité ouvre le Profil. Nouvelle clef i18n FR/EN pour le rôle.
- **#112 — pellicule d'images défilable.** La molette de la souris fait défiler
horizontalement la bande de miniatures et deux **flèches translucides** sur les
côtés (révélées au survol) remplissent la même fonction.
### Modifié
- **#112 — en-tête allégé.** La **version** quitte le header pour le menu
**Options** (ligne « Version ») ; le **bouton de déconnexion** et le **nom de
l'utilisateur** sont retirés du header au profit de la section compte de la
sidebar. Le header droit ne conserve que l'indicateur hors-ligne, le contexte
vault et le bouton Options.
---
## [2.20.0] — 2026-09-23
### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.20.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.23.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -975,8 +975,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.20.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.23.0).
---
*Projet : ObsiGate | Version : 2.20.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.23.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.20.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.23.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1150,8 +1150,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.20.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.23.0).
---
*Project: ObsiGate | Version: 2.20.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.23.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.20.0
2.23.0
+48 -2
View File
@@ -2,6 +2,8 @@
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
import base64
import binascii
import logging
import re
@@ -109,6 +111,43 @@ class UpdateUserRequest(BaseModel):
return validate_password_strength(v)
# ── Profile avatar (#113) ───────────────────────────────────────────
#: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface).
_AVATAR_DATA_URL_RE = re.compile(
r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$"
)
#: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom).
_AVATAR_MAX_CHARS = 400_000
def _validate_avatar(data_url: str) -> str | None:
"""Validate an avatar data-URL for storage on the user profile.
Returns the normalized data-URL, or ``None`` when clearing the avatar
(empty string). Raises ``HTTPException(400)`` on anything else.
"""
if data_url == "":
return None
if len(data_url) > _AVATAR_MAX_CHARS:
raise HTTPException(400, "Avatar image too large")
if not _AVATAR_DATA_URL_RE.match(data_url):
raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL")
try:
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards
raise HTTPException(400, "Avatar payload is not valid base64") from exc
# Confirm the decoded bytes really are a supported image (magic numbers).
is_png = raw.startswith(b"\x89PNG\r\n\x1a\n")
is_jpeg = raw.startswith(b"\xff\xd8\xff")
is_webp = (
len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP"
)
if not (is_png or is_jpeg or is_webp):
raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image")
return data_url
# ── Public endpoints ──────────────────────────────────────────────────
@router.get("/status")
@@ -221,6 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
"display_name": user["display_name"],
"role": user["role"],
"vaults": user["vaults"],
"avatar": user.get("avatar"),
},
}
@@ -343,6 +383,7 @@ async def get_me(current_user=Depends(require_auth)):
"vaults": current_user["vaults"],
"language": current_user.get("language", "fr"),
"last_login": current_user.get("last_login"),
"avatar": current_user.get("avatar"),
}
@@ -350,19 +391,23 @@ class UpdateMeRequest(BaseModel):
"""Fields the user can update on their own profile."""
display_name: str | None = None
language: str | None = None
#: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113).
avatar: str | None = None
@router.patch("/me")
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
"""Update current user's profile fields (display_name, language)."""
"""Update current user's profile fields (display_name, language, avatar)."""
from .user_store import update_user
updates = {}
updates: dict[str, object] = {}
if req.display_name is not None:
updates["display_name"] = req.display_name
if req.language is not None:
if req.language not in ("fr", "en"):
raise HTTPException(400, "language must be 'fr' or 'en'")
updates["language"] = req.language
if req.avatar is not None:
updates["avatar"] = _validate_avatar(req.avatar)
if not updates:
raise HTTPException(400, "No fields to update")
updated = update_user(current_user["username"], updates)
@@ -373,6 +418,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
"vaults": updated["vaults"],
"language": updated.get("language", "fr"),
"last_login": updated.get("last_login"),
"avatar": updated.get("avatar"),
}
+1
View File
@@ -96,6 +96,7 @@ def create_user(
"vaults": vaults or [],
"active": True,
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"last_login": None,
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.20.0"
version = "2.23.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.20.0"
version = "2.23.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.20.0",
"version": "2.23.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+4
View File
@@ -221,6 +221,10 @@ Gestion :
| `/api/auth/admin/users/{u}` | Modifier / supprimer | PATCH/DELETE | Admin |
| `/api/admin/stats` · `/audit` · `/backup-stats` · `/stream` | Monitoring admin | GET | Admin |
> `PATCH /api/auth/me` accepte `{"avatar": "<data-url>"}` (PNG/JPEG/WebP, 400 000
> caractères max, octets magiques contrôlés) ; `{"avatar": ""}` supprime la photo.
> La valeur est renvoyée par `GET /api/auth/me` et par le payload `user` du login.
### 3.10 Partage, webhooks, conflits, plugins, push
| Endpoint | Description | Méthode |
+5
View File
@@ -67,6 +67,11 @@ CHANGE THIS PASSWORD on first login!
Changez-le immédiatement (menu profil → *Changer le mot de passe*).
Vous pouvez aussi ajouter une **photo de profil** : *Configurations → Profil →
Choisir une image* (PNG, JPG ou WEBP, 8 Mo maximum — recadrée en carré 256 px).
Elle remplace les initiales dans le cercle du compte en bas de la sidebar et peut
être supprimée à tout moment depuis la même section.
---
## 3. Gestion des utilisateurs
+6
View File
@@ -142,6 +142,12 @@ Elle regroupe les vues principales via des icônes :
Un champ **« Filtrer fichiers… »** restreint l'arborescence en temps réel, et le
bouton **Aa** ajuste l'affichage des libellés.
En bas de la sidebar (si l'authentification est activée), la **section compte**
affiche votre avatar (ou vos initiales), votre nom et votre rôle ; un clic ouvre
le profil. La photo se choisit dans **Configurations → Profil** (*Choisir une
image* : PNG, JPG ou WEBP — recadrée en carré 256 px, affichée dans le cercle de
la sidebar) ; le bouton *Se déconnecter* est juste à côté.
### 5.3 La zone de contenu
Elle affiche l'onglet actif : tableau de bord **Statistiques**, **Bookmarks**,
+3
View File
@@ -181,6 +181,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
| *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -260,6 +261,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
---
+7 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.20.0 | **Dernière mise à jour :** 2026-09-23
> **Version :** 2.23.0 | **Dernière mise à jour :** 2026-09-24
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -40,6 +40,8 @@
- [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
---
## ⚪ Backlog — Priorité 4 (P4)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
@@ -184,6 +186,9 @@
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) |
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
---
@@ -191,7 +196,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–111, #92 | ~130 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–114, #92 | ~133 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
+69
View File
@@ -0,0 +1,69 @@
# #112 — En-tête allégé & compte en sidebar
> **Version livrée :** 2.21.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/auth.js`, `frontend/js/viewer.js`,
> `frontend/style.css`, i18n FR/EN) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Le header concentrait plusieurs éléments redondants ou peu lisibles :
- la **version** était affichée en permanence à côté du bouton Options ;
- un **bouton de déconnexion** et le **nom de l'utilisateur** occupaient la zone
droite du header, sans regroupement logique ;
- la pellicule de miniatures de la visionneuse d'images ne pouvait être
parcourue qu'au glisser (pas de molette horizontale ni de flèches dédiées).
## Ce qui a été livré
### A. Header allégé
- **Version déplacée** du header vers le menu **Options** : nouvelle ligne
non-interactive « Version » (`#version-badge` conservé pour `populateVersions`).
- **Suppression** du bloc `#user-menu` (nom + bouton « sign out ») et de la
ligne « Déconnexion » du menu Options. Le header droit ne garde que l'indicateur
hors-ligne, le contexte vault et le bouton Options.
### B. Section compte en bas de la sidebar
- Nouveau bloc `#sidebar-user` épinglé au bas de la sidebar (`flex-shrink: 0`,
bordure supérieure), à la manière d'un site web professionnel :
- initiales dans un **avatar** circulaire (dégradé d'accent) ;
- **nom** (`#sidebar-user-name`, classe `.user-display-name` conservée pour la
réutilisation par la collaboration) et **rôle** localisé
(Administrateur / Utilisateur) ;
- **bouton déconnexion** (`#sidebar-user-logout`) ;
- clic sur l'identité → **Profil** (ouvre la section `#cfg-profile`).
- `AuthManager.renderUserSection()` peuple le bloc ; il reste masqué quand
l'authentification est désactivée (`#sidebar-user[hidden]`). `renderUserMenu()`
est conservé comme alias rétro-compatible.
- La règle mobile qui masquait `.user-display-name` est limitée au header
(`.header-right .user-display-name`) pour que le nom reste visible dans la
sidebar mobile.
### C. Pellicule d'images défilable (#112, complément de #111)
- Nouveau conteneur `.image-nav` autour de la pellicule, avec deux **flèches
translucides** fixes (`.image-strip-arrow-prev/next`) révélées au survol.
- **Molette** au-dessus de la pellicule → défilement horizontal
(`strip.scrollLeft += deltaY`), conversion des deltas verticaux.
- Les flèches font défiler d'une « page » (`strip.scrollBy`, défilement doux).
## Tests
- `tests/frontend/unit.test.mjs` (+1) : header nettoyé (plus de `#user-menu`,
`#logout-btn`, ni version dans `.header-right`), version dans le menu Options,
présence de `#sidebar-user`, styles `.sidebar-user`/`.menu-list-version`, clés
i18n FR/EN.
- `tests/frontend/image-viewer.test.mjs` (+1) : wrapper `.image-nav`, flèches de
pellicule, molette et `scrollBy`.
- `tests/e2e/header-sidebar.spec.js` (nouveau) : test header/version exécuté
partout ; test compte en sidebar conditionné à l'auth (skip sinon).
- CI : `image-viewer.test.mjs` ajouté au job `lint` (il n'y était pas depuis #108).
## Limitations connues
- La section compte n'apparaît que si l'authentification est activée.
- `doLogoutFallback` (script inline supprimé avec la ligne du menu) reste appelé
par la section Profil uniquement en repli ; `window.handleLogout` est toujours
défini par `auth.js`, donc le repli n'est jamais atteint.
+172
View File
@@ -0,0 +1,172 @@
# #114 — Configuration — refonte mobile-responsive de la section Settings
> **Statut :** 🟢 · **Impact :** 🟡 · **Zone :** frontend (mobile, ≤ 768 px)
> **Fichiers :** `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`,
> `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`,
> `tests/e2e/config-mobile.spec.js`.
## Contexte
La page **Configurations** (`#config-modal`) était utilisable en mobile seulement
partiellement (correctifs BUG-071) : le sommaire s'ouvrait en bloc haut, la modale
n'était pas plein écran, les cibles tactiles étaient sous 44 px, le clavier virtuel
iOS zoomait les champs, la rangée « Sauvegarder » disparaissait au scroll et plusieurs
dettes HTML/i18n étaient restées en place.
## Ce qui a été livré
### A. Sommaire en drawer coulissant
- `#config-nav` devient un **panneau coulissant gauche** (`position: fixed`,
`width: min(320px, 88vw)`, `z-index: 40`) sous un fond assombri
(`#config-modal.config-toc-open::before`, `z-index: 35`).
- Ouverture/fermeture pilotée par la classe **`.config-toc-open`** sur `#config-modal`
(JS `_setConfigNav`) + un `display` inline conservé pour le contrat de reset.
- Bouton **`#config-toc-close`** (classe `.help-toc-close`, `aria-label`
`config.toc_close`) dans l'en-tête du drawer ; visible uniquement dans le drawer
de la config (masqué sur desktop où la nav est toujours visible).
- **Backdrop** : un tap hors du drawer ferme d'abord le drawer, pas la modale
(`e.target === modal` → `config-toc-open` présent → `_setConfigNav(false)`).
- **Échap** : ferme le drawer d'abord, puis la modale.
- Fermeture de la modale (`closeConfigModal`) nettoie toujours la classe
`config-toc-open` et le `display` inline (aucun fond ne subsiste).
- Animation `config-toc-slide-in` (translateX) à l'ouverture.
### B. Modale plein écran
- `#config-modal` : `padding: 0`, `.editor-container` en `100vw × 100dvh`
(`100dvh` = hauteur du viewport dynamique, tient compte de la barre du navigateur
mobile), `border-radius: 0`, `border: none`.
- Le contenu (`#config-scroll`) garde son scroll propre ; le sous-bloc
`.config-content` reçoit un `padding-bottom: 80 px` pour ne jamais passer sous la
rangée sticky.
### C. Cibles tactiles ≥ 44 px & anti-zoom iOS
- **Champs** : `.config-input`, `.config-select`, `.help-nav-search`,
`.profile-field .config-input/.config-select` → `min-height: 44px` +
`font-size: 16px` (**anti-zoom iOS** : un `font-size < 16px` déclenche le zoom
automatique au focus). La règle est **scoppée `#config-modal`** pour ne pas écraser
`.mfa-code-input` (qui a sa propre typographie).
- **Boutons** : `.config-btn-save`, `.config-btn-secondary`, `.config-btn-primary`,
`.config-btn-danger`, `.config-btn-add`, `.config-btn-sm`, `.mfa-link-btn`,
`.theme-action-btn`, `.profile-avatar-actions .config-btn-secondary`,
`.editor-btn` (fermer), `#config-hamburger`, `#config-toc-close`,
`.help-search-clear` → `min-height/min-width: 44px`.
- **Liens du sommaire** : `.help-nav-link` → `min-height: 44px` (ligne tactile
confortable).
- `.help-hamburger` passe de 36 px à **44 px** en mobile (règle partagée avec le
modal d'aide).
### D. Rangée « Sauvegarder » sticky
- `.config-actions-row` (dans `#cfg-backend-settings`) → `position: sticky;
bottom: 0`, empilée verticalement (`flex-direction: column`), boutons pleine
largeur 44 px, fond opaque + bordure, `padding-bottom` avec
`env(safe-area-inset-bottom)` (barre home iOS).
- La rangée reste visible pendant le scroll de la section backend ; le
`padding-bottom: 80px` de `.config-content` garantit qu'elle ne masque jamais les
derniers contrôles.
### E. Formulaires 1 colonne & grilles
- `.config-row` → 1 colonne (`grid-template-columns: 1fr`), `.config-input--num`
pleine largeur, `text-align: left`.
- `.ai-default-grid` / `.ai-provider-fields` → 1 colonne.
- Add-rows (`.config-add-row`, `.config-add-pattern`) → wrap + largeurs inline
(`180/140/100px`) neutralisées (`width: auto !important`).
- Items webhook/token/share → wrap ; URLs/méta sur leur propre ligne
(`overflow-wrap: anywhere`) ; boutons de suppression 44 px.
- `.hidden-files-add-row` → wrap, input pleine largeur.
- `.config-diag-row` → wrap.
- `.profile-avatar-row` → wrap ; `.profile-form` → `max-width: 100%`.
- `.webauthn-key-item` → wrap ; `.webauthn-key-label` → pleine largeur.
- `.theme-grid` reste en `auto-fill minmax(160px, 1fr)` (déjà responsive).
### F. MFA & sécurité
- `.mfa-recovery-list` → **1 colonne** en mobile (2 colonnes illisibles à 360 px).
- `.mfa-verify-section`, `.mfa-recovery-actions`, `.mfa-disable-actions` → wrap ;
champs/boutons enfants en pleine largeur.
- `.mfa-code-input` → `width: 100%`, `max-width: 320px`, `letter-spacing: 6px`
(au lieu de 12 px qui débordait), `min-height: 52px`.
- `.mfa-secret-code` → `word-break: break-all` (secret TOTP long).
- `.mfa-code-input-group` → wrap.
- Règle morte **`.mfa-recovery-input`** supprimée (auth.js utilise
`mfa-code-input recovery-input`).
### G. Dettes HTML corrigées
- `.config-actions-row` (Sauvegarder / Réindexer / Réinitialiser) déplacée
**dans** `#cfg-backend-settings` (elle était hors de toute section → le sticky
n'avait pas de conteneur de scroll fiable) ; `</section>` orphelin supprimé.
- Id dupliqué **`cfg-partages-publics`** retiré du `<h2>` (l'id reste sur la
`<section>`, cf. #113).
- Conteneur mort **`#plugins-settings-container`** supprimé (le rendu réel est
`#cfg-plugins` via `plugins.js`).
- Sections plugins/about ré-indentées.
- **`#mt-explorer`** : libellé brut `settings.search` remplacé par
`<span data-i18n="settings.explorer">` (i18n correct, FR « Explorateur » / EN
« Files »).
- Doublons CSS **`.config-btn-add`** (3 définitions) réduits à la définition de
référence.
### H. i18n FR/EN
- **Purge des clés mortes** (aucune référence HTML/JS/tests/backend) :
`settings.backend`, `settings.backend_hint`, `settings.restart_badge`,
`settings.save`, `settings.plugins`.
- **Ajouts** : `settings.explorer` (FR « Explorateur » / EN « Files »),
`config.toc_close` (FR « Fermer le sommaire » / EN « Close contents »).
- **Correctif** : `settings.tabs` en FR était le mot anglais « Tabs » →
**« Onglets »** (EN reste « Tabs »).
- Clés vivantes conservées : `settings.reindex`, `settings.no_restart_badge`,
`settings.backend_section`, `settings.security`, `settings.search`,
`settings.tabs`, `settings.search_placeholder`.
## Tests
### Statics — `tests/frontend/config-mobile.test.mjs` (27, au CI)
- BUG-071a–e (hamburger, toggle JS, grilles/wrap, ancres mortes,
`data-i18n-attr` multi-paires) — conservés et adaptés au drawer.
- **#114a** : `#config-toc-close` présent + i18n ; `_setConfigNav` bascule
`.config-toc-open` ; backdrop `::before` (z-index 35) ; `.help-toc-close`
masqué sur desktop / visible dans le drawer ; Échap et backdrop ferment le
drawer d'abord ; `closeConfigModal` nettoie la classe.
- **#114b** : modale `100dvh` + `padding: 0` ; inputs/selects `16px` +
`44px` ; boutons `44px` ; rangée sticky (`position: sticky` +
`flex-direction: column` + safe-area) ; MFA 1 colonne + wrap + code
full-width ; `.config-actions-row` bien dans `#cfg-backend-settings`.
- **#114i18n** : clés mortes purgées ; `settings.explorer` FR/EN ;
`settings.tabs` FR = « Onglets » ; `#mt-explorer` porte
`data-i18n="settings.explorer"` ; `#plugins-settings-container` absent.
### E2E — `tests/e2e/config-mobile.spec.js` (5, projet `chromium-mobile`)
1. Hamburger → drawer `position: fixed` + classe `config-toc-open` sur la modale.
2. Bouton `#config-toc-close` et tap backdrop ferment le drawer **sans** fermer la
modale.
3. Sélection d'une section → scroll doux + lien actif + repli du drawer.
4. Modale plein écran (largeur/hauteur ≈ viewport) + `#config-hamburger`,
`#config-close` et `#cfg-save-backend` ≥ 44 px.
5. Aucun débordement horizontal à 393 px (sections IA / tokens / webhooks /
partages).
## Détails d'implémentation notables
- **Spécificité CSS** : les règles `#config-modal #config-nav` (2 ids) priment sur
les règles génériques `.help-nav` / `body .help-nav` qui masquent la nav en
mobile — pas besoin de `!important`.
- **Backdrop = pseudo-élément** : les clics sur `::before` sont attribués à
l'élément or (`#config-modal`), donc le handler `e.target === modal` existant
fonctionne sans node supplémentaire.
- **Contrat de reset conservé** : `configNavOnOpen.style.display = ''` à
l'ouverture (test statique BUG-071b) — le CSS reprend la main (drawer masqué par
défaut sur mobile).
- **`100dvh` avec repli `100vh`** : les navigateurs sans support `dvh` gardent le
comportement précédent.
- La règle `.help-hamburger { display: inline-flex }` du bloc mobile du modal
d'aide est **partagée** (44 px) ; le drawer de la config double avec
`#config-modal .help-hamburger` pour rester robuste à un réordonnancement des
règles.
@@ -0,0 +1,94 @@
# #113 — Ordre naturel des sections Configurations & avatar utilisateur
> **Version livrée :** 2.22.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/config.js`, `frontend/js/auth.js`,
> `frontend/style.css`, i18n FR/EN) + backend (`backend/auth/router.py`,
> `backend/auth/user_store.py`) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Deux irritants sur la page **Configurations** :
- l'ordre des sections était historique et peu naturel (Recherche en tête, Profil
noyé en position 11, À propos au milieu) ;
- la section **Profil** ne permettait pas de personnaliser l'image affichée dans le
cercle du compte en bas de la sidebar (initiales uniquement).
## Ce qui a été livré
### A. Ordre naturel des sections (TOC = page)
Nouvel ordre, appliqué **à la liste `<ul class="help-nav-list">` (TOC) et aux
`<section>` de la page**, dans le même ordre :
1. **Profil** (`#cfg-profile`) — en premier
2. Sécurité du compte (`#cfg-security`)
3. Thèmes (`#cfg-themes`)
4. Paramètres de recherche (`#cfg-search`)
5. Historique récent (`#cfg-recent`)
6. Filtrage de tags (`#cfg-tags`)
7. Fichiers cachés (`#cfg-hidden-files`)
8. Synchronisation (`#cfg-sync`)
9. Paramètres backend (`#cfg-backend-settings`)
10. Diagnostics (`#cfg-diags`)
11. Clés API IA (`#cfg-ai`)
12. Sources connectées (`#cfg-sources`)
13. Clés API & MCP (`#cfg-tokens`)
14. Notifications push (`#cfg-push`)
15. Webhooks (`#cfg-webhooks`)
16. Partages publics (`#cfg-partages-publics`)
17. Plugins (`#cfg-plugins`)
18. **À propos** (`#cfg-about`) — en dernier
Regroupement retenu : **Compte & apparence → Navigation & contenu → Système →
Intégrations & notifications → À propos**.
Les ancres `cfg-tags` et `cfg-partages-publics` étaient posées sur un `<h2>` à
l'intérieur d'une `<section>` sans id : elles sont désormais portées par la
`<section>` elle-même, pour que le scroll vise le haut de la section (comme toutes
les autres).
### B. Avatar utilisateur ( Profil )
- **Import d'image** : bouton « Choisir une image » + overlay caméra au survol de
l'aperçu circulaire (88 px) → `<input type="file" accept="image/png,image/jpeg,image/webp">`.
- **Traitement client** (`config.js`) : garde type (PNG/JPEG/WEBP) et taille brute
(8 Mo), **recadrage carré central** et redimensionnement à **256 px** via canvas,
export JPEG qualitée 0,85 (fond blanc pour les PNG transparents).
- **Persistance serveur** : `PATCH /api/auth/me` avec `{"avatar": "<data-url>"}` ;
`""` supprime. Validation stricte dans `backend/auth/router.py`
(`_validate_avatar`) : data-URL PNG/JPEG/WebP uniquement (**SVG refusé** — surface
XSS), plafond 400 000 caractères, base64 valide et **octets magiques** contrôlés.
Champ `avatar` ajouté à `data/users.json` (`create_user`), renvoyé par
`GET/PATCH /api/auth/me` et par le payload `user` de login.
- **Affichage sidebar** (`auth.js`) : `renderUserSection()` insère un
`<img class="sidebar-user-avatar-img">` dans `#sidebar-user-avatar` quand
`user.avatar` est défini, sinon les initiales (repli inchangé). Helpers
`AuthManager.updateCachedUser()` et `AuthManager.isAuthEnabled()`.
- **Suppression** : bouton « Supprimer la photo » (stylisté danger), revenu aux
initiales.
- **Auth désactivée** : le bloc avatar est masqué (pas de compte, sidebar masquée).
- Feedback : toasts `config.avatar_updated` / `config.avatar_removed`, erreurs en
ligne (`config.avatar_invalid_type`, `config.avatar_too_large`,
`config.avatar_upload_failed`).
## Tests
- `tests/test_auth_api.py` — classe `TestAvatar` (+8) : GET/PATCH exposent
l'avatar, data-URL PNG acceptée, `""` efface, SVG refusé, payload non-image refusé,
trop grand refusé, base64 invalide refusé, avatar présent dans le payload de login.
- `tests/frontend/settings-order-avatar.test.mjs` (nouveau, ajouté au job `lint`) —
9 tests : ordre TOC (Profil 1er, À propos dernier), ordre de page strictement
identique à la TOC, aucune ancre morte / section orpheline, présence de l'UI avatar
dans `#cfg-profile`, flux `config.js` (types, taille, resize, PATCH, rafraîchissement
sidebar), rendu `auth.js`, règles CSS, clés i18n FR/EN, validation backend.
- Vérifications locales : pytest 1302 passed, ruff/mypy 0 erreur, tests frontend
statiques + JSDOM verts.
## Limitations connues
- L'avatar est stocké en data-URL dans `data/users.json` (adéquat pour un usage
personnel ; un stockage fichier dédié restera possible si les comptes se multiplient).
- La conversion GIF/animé n'est pas prise en charge (types PNG/JPEG/WEBP uniquement).
- Le nom d'affichage du profil reste local (`localStorage`) et n'est pas poussé au
serveur (comportement antérieur conservé).
+384 -300
View File
@@ -519,9 +519,6 @@
></i>
<span id="vault-context-text">All</span>
</div>
<!-- User menu (visible when logged in) -->
<div class="user-menu" id="user-menu"></div>
<div class="version-badge" id="version-badge" title="Version ObsiGate"></div>
<div class="header-menu">
<button
class="header-menu-btn"
@@ -770,33 +767,33 @@
>
</span>
</button>
<script>function doLogoutFallback(){var x=new XMLHttpRequest();x.open('POST','/api/auth/logout');x.withCredentials=true;x.onload=function(){var a=document.getElementById('app');var l=document.getElementById('login-screen');if(a)a.classList.add('hidden');if(l)l.classList.remove('hidden');try{localStorage.removeItem('obsigate-token')}catch(e){}};x.send()}</script>
<button
class="menu-list-row menu-list-button"
id="logout-btn"
type="button"
role="menuitem"
onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}"
<div
class="menu-list-row menu-list-version"
role="none"
>
<span
class="menu-list-icon"
aria-hidden="true"
>
<i
data-lucide="log-out"
style="width: 17px; height: 17px; color: var(--danger)"
data-lucide="tag"
style="width: 17px; height: 17px"
></i>
</span>
<span class="menu-list-content">
<span class="menu-list-title" data-i18n="header.menu_logout"
style="color:var(--danger)"
>Deconnexion</span
>
<span class="menu-list-subtitle" data-i18n="header.menu_logout_desc"
>Quitter la session</span
<span class="menu-list-title" data-i18n="header.menu_version"
>Version</span
>
<span class="menu-list-subtitle"
>ObsiGate
<span
class="version-badge"
id="version-badge"
title="Version ObsiGate"
></span
></span>
</span>
</button>
</div>
</div>
</div>
</div>
@@ -1052,6 +1049,43 @@
<span>Aucune conversation</span>
</div>
</div>
<!-- User section (bottom of the sidebar, #112) -->
<div class="sidebar-user" id="sidebar-user" hidden>
<button
class="sidebar-user-profile"
id="sidebar-user-profile"
type="button"
title="Profil"
>
<span
class="sidebar-user-avatar"
id="sidebar-user-avatar"
aria-hidden="true"
></span>
<span class="sidebar-user-meta">
<span
class="sidebar-user-name user-display-name"
id="sidebar-user-name"
></span>
<span
class="sidebar-user-role"
id="sidebar-user-role"
></span>
</span>
</button>
<button
class="sidebar-user-logout"
id="sidebar-user-logout"
type="button"
title="Déconnexion"
>
<i
data-lucide="log-out"
style="width: 16px; height: 16px"
></i>
</button>
</div>
</aside>
<!-- Sidebar resize handle -->
@@ -1517,6 +1551,17 @@
<nav class="help-nav" id="config-nav">
<div class="help-nav-header">
<div class="help-nav-title" data-i18n="config.title">Configuration</div>
<button
class="help-toc-close"
id="config-toc-close"
data-i18n-attr="aria-label:config.toc_close"
aria-label="Fermer le sommaire"
>
<i
data-lucide="x"
style="width: 16px; height: 16px"
></i>
</button>
</div>
<div class="help-nav-search-wrap">
<input
@@ -1547,28 +1592,131 @@
</button>
</div>
<ul class="help-nav-list">
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-search" class="help-nav-link" data-i18n="help.nav_search"></a></li>
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
<li><a href="#cfg-ai" class="help-nav-link" data-i18n="settings.ai"></a></li>
<li><a href="#cfg-sources" class="help-nav-link" data-i18n="config.section_sources">Sources connectées</a></li>
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-tokens" class="help-nav-link" data-i18n="config.nav_tokens">🔑 Clés API & MCP</a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
</ul>
</nav>
<div class="help-content" id="config-scroll">
<div class="config-content">
<!-- Profil -->
<section
class="config-section help-section"
id="cfg-profile"
>
<h2 data-i18n="config.profile">👤 Profil utilisateur</h2>
<p class="config-description" data-i18n="config.profile_desc">
Préférences personnelles pour personnaliser
l'expérience ObsiGate.
</p>
<div class="profile-form" id="profile-form">
<div class="profile-field profile-avatar-field" id="profile-avatar-field">
<span class="profile-avatar-label" id="profile-avatar-label" data-i18n="config.avatar_label">Photo de profil</span>
<div class="profile-avatar-row">
<div
class="profile-avatar-preview"
id="profile-avatar-preview"
data-i18n-attr="title:config.avatar_choose;aria-label:config.avatar_choose"
title="Choisir une image"
aria-label="Choisir une image"
>
<img id="profile-avatar-img" alt="" hidden />
<span class="profile-avatar-initials" id="profile-avatar-initials" aria-hidden="true"></span>
<button
type="button"
class="profile-avatar-overlay"
id="profile-avatar-overlay"
data-i18n-attr="title:config.avatar_choose;aria-label:config.avatar_choose"
title="Choisir une image"
aria-label="Choisir une image"
>
<i data-lucide="camera" style="width: 18px; height: 18px"></i>
</button>
</div>
<div class="profile-avatar-actions">
<button type="button" class="config-btn-secondary" id="profile-avatar-choose" data-i18n="config.avatar_choose">Choisir une image</button>
<button type="button" class="config-btn-secondary profile-avatar-remove-btn" id="profile-avatar-remove" data-i18n="config.avatar_remove" hidden>Supprimer la photo</button>
<input
type="file"
id="profile-avatar-input"
accept="image/png,image/jpeg,image/webp"
hidden
/>
<span class="profile-hint" data-i18n="config.avatar_hint">PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.</span>
</div>
</div>
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
</div>
<div class="profile-field">
<label for="profile-lang" data-i18n="config.lang_label">Langue préférée</label>
<select id="profile-lang" class="config-select">
<option value="fr">Francais</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="de">Deutsch</option>
</select>
<span class="profile-hint" data-i18n="config.lang_hint">Utilisée par l'autocomplétion et le ghost text.</span>
</div>
<div class="profile-field">
<label for="profile-name" data-i18n="config.display_name">Nom d'affichage</label>
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
<!-- Sécurité du compte (MFA) -->
<section
class="config-section help-section"
id="cfg-security"
>
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
<p class="config-description" data-i18n="settings.security_desc">
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
</p>
<div id="mfa-settings">
<div id="mfa-status" class="mfa-status-section">
<div class="mfa-status-row">
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
</div>
<div id="mfa-setup-area"></div>
</div>
</div>
</section>
<!-- Themes -->
<section
class="config-section help-section"
id="cfg-themes"
>
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
<p class="config-description" data-i18n="auto.caac40b9">
Choisissez un thème visuel. Chaque thème
offre un mode sombre, clair, contraste élevé et sépia.
</p>
<div class="theme-grid" id="theme-grid">
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
</div>
</section>
<!-- Performance Settings - Frontend -->
<section
class="config-section help-section"
@@ -1714,6 +1862,182 @@
</div>
</section>
<!-- Tag Filtering (existing) -->
<section class="config-section help-section" id="cfg-tags">
<h2 data-i18n="config.tag_filtering_label">Filtrage de tags</h2>
<p class="config-description" data-i18n="auto.e0b8a606">
Définissez les patterns de tags à masquer
dans la sidebar. Vous pouvez utiliser des
wildcards pour cibler les tags de template.
</p>
<div
class="config-filters-list"
id="config-filters-list"
></div>
<div class="config-add-pattern">
<input
type="text"
id="config-pattern-input"
placeholder="Ex: #&lt;% ... %&gt; ou #{{ ... }}"
class="config-input"
/>
<button id="config-add-btn"
class="config-btn-add" data-i18n="config.add_webhook">
Ajouter
</button>
</div>
<div
class="config-regex-preview"
id="config-regex-preview"
style="display: none"
>
<small
>Regex :
<code id="config-regex-code"></code
></small>
</div>
</section>
<!-- Hidden Files/Folders Configuration -->
<section id="cfg-hidden-files" class="config-section help-section">
<h2 data-i18n="auto.f8ba6127">🗂️ Fichiers cachés</h2>
<p class="config-description">
Contrôlez l'affichage des fichiers/dossiers
cachés (commençant par <code>.</code>) par
vault.
</p>
<p
class="config-hint"
style="
margin-bottom: 12px;
padding: 8px;
background: var(--background-secondary);
border-radius: 4px;
"
>
i️ <strong data-i18n="config.note">Note :</strong> Tous les fichiers
sont toujours indexés et cherchables. Ce
paramètre contrôle uniquement leur
visibilité dans l'interface.
</p>
<div id="hidden-files-vault-list">
<!-- Vault-specific settings will be injected here -->
</div>
<div
class="config-actions-row"
style="margin-top: 16px"
>
<button
class="config-btn-save"
id="cfg-save-hidden-files"
>
💾 Sauvegarder
</button>
</div>
</section>
<!-- Watcher / Synchronisation -->
<section
class="config-section help-section"
id="cfg-sync"
>
<h2 data-i18n="config.section_watcher">Synchronisation automatique</h2>
<p class="config-description" data-i18n="auto.d33601dc">
Surveillance des fichiers en temps réel via
watchdog. Les modifications sont détectées
automatiquement et l'index est mis à jour
sans redémarrage.
</p>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-enabled"
>Activer la surveillance</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-enabled"
checked
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Activer/désactiver la surveillance
automatique des fichiers</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-polling"
>Mode polling (fallback)</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-polling"
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Forcer le mode polling au lieu de
inotify natif (utile si le mode natif ne
fonctionne pas)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-interval"
>Intervalle polling (s)</label
>
<input
type="number"
id="cfg-watcher-interval"
class="config-input config-input--num"
min="1"
max="30"
step="1"
value="5"
/>
<span class="config-hint"
>Intervalle de scrutation en mode
polling (1-30 secondes)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-debounce"
>Debounce (s)</label
>
<input
type="number"
id="cfg-watcher-debounce"
class="config-input config-input--num"
min="0.5"
max="10"
step="0.5"
value="2"
/>
<span class="config-hint"
>Délai avant traitement des changements
groupés (0.5-10 secondes)</span
>
</div>
</section>
<!-- Performance Settings - Backend -->
<section id="cfg-backend-settings" class="config-section help-section">
<h2 data-i18n="config.section_backend">
@@ -1833,7 +2157,6 @@
>Nombre max de tokens élargis par préfixe (10-200)</span
>
</div>
</section>
<div class="config-actions-row">
<button class="config-btn-save"
id="cfg-save-backend" data-i18n="help.shortcut_save">
@@ -1850,182 +2173,6 @@
</div>
</section>
<!-- Tag Filtering (existing) -->
<section class="config-section help-section">
<h2 id="cfg-tags" data-i18n="config.tag_filtering_label">Filtrage de tags</h2>
<p class="config-description" data-i18n="auto.e0b8a606">
Définissez les patterns de tags à masquer
dans la sidebar. Vous pouvez utiliser des
wildcards pour cibler les tags de template.
</p>
<div
class="config-filters-list"
id="config-filters-list"
></div>
<div class="config-add-pattern">
<input
type="text"
id="config-pattern-input"
placeholder="Ex: #&lt;% ... %&gt; ou #{{ ... }}"
class="config-input"
/>
<button id="config-add-btn"
class="config-btn-add" data-i18n="config.add_webhook">
Ajouter
</button>
</div>
<div
class="config-regex-preview"
id="config-regex-preview"
style="display: none"
>
<small
>Regex :
<code id="config-regex-code"></code
></small>
</div>
</section>
<!-- Watcher / Synchronisation -->
<section
class="config-section help-section"
id="cfg-sync"
>
<h2 data-i18n="config.section_watcher">Synchronisation automatique</h2>
<p class="config-description" data-i18n="auto.d33601dc">
Surveillance des fichiers en temps réel via
watchdog. Les modifications sont détectées
automatiquement et l'index est mis à jour
sans redémarrage.
</p>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-enabled"
>Activer la surveillance</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-enabled"
checked
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Activer/désactiver la surveillance
automatique des fichiers</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-polling"
>Mode polling (fallback)</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-polling"
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Forcer le mode polling au lieu de
inotify natif (utile si le mode natif ne
fonctionne pas)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-interval"
>Intervalle polling (s)</label
>
<input
type="number"
id="cfg-watcher-interval"
class="config-input config-input--num"
min="1"
max="30"
step="1"
value="5"
/>
<span class="config-hint"
>Intervalle de scrutation en mode
polling (1-30 secondes)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-debounce"
>Debounce (s)</label
>
<input
type="number"
id="cfg-watcher-debounce"
class="config-input config-input--num"
min="0.5"
max="10"
step="0.5"
value="2"
/>
<span class="config-hint"
>Délai avant traitement des changements
groupés (0.5-10 secondes)</span
>
</div>
</section>
<!-- Hidden Files/Folders Configuration -->
<section id="cfg-hidden-files" class="config-section help-section">
<h2 data-i18n="auto.f8ba6127">🗂️ Fichiers cachés</h2>
<p class="config-description">
Contrôlez l'affichage des fichiers/dossiers
cachés (commençant par <code>.</code>) par
vault.
</p>
<p
class="config-hint"
style="
margin-bottom: 12px;
padding: 8px;
background: var(--background-secondary);
border-radius: 4px;
"
>
i️ <strong data-i18n="config.note">Note :</strong> Tous les fichiers
sont toujours indexés et cherchables. Ce
paramètre contrôle uniquement leur
visibilité dans l'interface.
</p>
<div id="hidden-files-vault-list">
<!-- Vault-specific settings will be injected here -->
</div>
<div
class="config-actions-row"
style="margin-top: 16px"
>
<button
class="config-btn-save"
id="cfg-save-hidden-files"
>
💾 Sauvegarder
</button>
</div>
</section>
<!-- Diagnostics -->
<section
class="config-section help-section"
@@ -2282,72 +2429,6 @@
</div>
</section>
<!-- Themes -->
<section
class="config-section help-section"
id="cfg-themes"
>
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
<p class="config-description" data-i18n="auto.caac40b9">
Choisissez un thème visuel. Chaque thème
offre un mode sombre, clair, contraste élevé et sépia.
</p>
<div class="theme-grid" id="theme-grid">
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
</div>
</section>
<!-- Profil -->
<section
class="config-section help-section"
id="cfg-profile"
>
<h2 data-i18n="config.profile">👤 Profil utilisateur</h2>
<p class="config-description" data-i18n="config.profile_desc">
Préférences personnelles pour personnaliser
l'expérience ObsiGate.
</p>
<div class="profile-form" id="profile-form">
<div class="profile-field">
<label for="profile-lang" data-i18n="config.lang_label">Langue préférée</label>
<select id="profile-lang" class="config-select">
<option value="fr">Francais</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="de">Deutsch</option>
</select>
<span class="profile-hint" data-i18n="config.lang_hint">Utilisée par l'autocomplétion et le ghost text.</span>
</div>
<div class="profile-field">
<label for="profile-name" data-i18n="config.display_name">Nom d'affichage</label>
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
<!-- Sécurité du compte (MFA) -->
<section
class="config-section help-section"
id="cfg-security"
>
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
<p class="config-description" data-i18n="settings.security_desc">
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
</p>
<div id="mfa-settings">
<div id="mfa-status" class="mfa-status-section">
<div class="mfa-status-row">
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
</div>
<div id="mfa-setup-area"></div>
</div>
</div>
</section>
<!-- Clés API / MCP (#107) -->
<section id="cfg-tokens" class="config-section help-section">
<h2 data-i18n="config.section_tokens">🔑 Clés API &amp; MCP</h2>
@@ -2406,34 +2487,6 @@
</div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
<div id="plugins-settings-container"></div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
class="config-diagnostics"
>
<div class="config-diag-loading" data-i18n="common.loading">
Chargement...
</div>
</div>
</section>
<!-- Webhooks -->
<section id="cfg-webhooks" class="config-section help-section">
<h2>🔔 Webhooks</h2>
@@ -2465,14 +2518,41 @@
</section>
<!-- Partages publics -->
<section class="config-section help-section">
<h2 id="cfg-partages-publics">📤 Partages publics</h2>
<section class="config-section help-section" id="cfg-partages-publics">
<h2>📤 Partages publics</h2>
<p class="config-description">
Liens de partage publics pour des documents
(lecture seule, sans authentification).
</p>
<div id="shares-list"></div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
class="config-diagnostics"
>
<div class="config-diag-loading" data-i18n="common.loading">
Chargement...
</div>
</div>
</section>
</div>
</div>
</div>
@@ -3293,6 +3373,10 @@
<strong data-i18n="sidebar.tab_tags">Tags</strong> <span data-i18n="help.desc_tags_tab">: Nuage de tags
cliquables</span>
</li>
<li>
<strong data-i18n="help.sidebar_user">Compte</strong> <span data-i18n="help.desc_sidebar_user">: Avatar, nom,
rôle et déconnexion en bas de la sidebar</span>
</li>
</ul>
<p data-i18n="help.sidebar_features">Fonctionnalités de la sidebar :</p>
<ul>
@@ -5539,7 +5623,7 @@ curl -X POST https://votre-serveur.com/webhook \
data-lucide="folder-open"
style="width: 20px; height: 20px"
></i>
<span class="mt-label">settings.search</span>
<span class="mt-label" data-i18n="settings.explorer">Explorateur</span>
</button>
<button
class="mt-btn"
+69 -9
View File
@@ -1,7 +1,7 @@
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
import { state } from './state.js';
import { safeCreateIcons, escapeHtml } from './utils.js';
import { showToast, closeHeaderMenu } from './ui.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, getLocale, setLocale } from './i18n.js';
import { showWelcome } from './viewer.js';
@@ -13,6 +13,15 @@ window.handleLogout = () => {
AuthManager.logout();
};
/** Two-letter initials for the sidebar account avatar (#112). */
function userInitials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
const first = parts[0][0] || "";
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
@@ -116,6 +125,18 @@ const AuthManager = {
return raw ? JSON.parse(raw) : null;
},
/** Merge fields into the cached user object (#113 — profile avatar). */
updateCachedUser(fields) {
const next = { ...(this.getUser() || {}), ...fields };
sessionStorage.setItem(this.USER_KEY, JSON.stringify(next));
return next;
},
/** Whether authentication is enabled on this instance (#113). */
isAuthEnabled() {
return !!this._authEnabled;
},
isTokenExpired() {
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
if (!expiry) return true;
@@ -323,22 +344,58 @@ const AuthManager = {
const app = document.getElementById("app");
if (login) login.classList.add("hidden");
if (app) app.classList.remove("hidden");
this.renderUserMenu();
this.renderUserSection();
},
renderUserMenu() {
/**
* Render the account block pinned at the bottom of the sidebar (#112) —
* the user identity/logout no longer live in the header.
*/
renderUserSection() {
const user = this.getUser();
const userMenu = document.getElementById("user-menu");
if (!userMenu) return;
const section = document.getElementById("sidebar-user");
if (!user || !this._authEnabled) {
userMenu.innerHTML = "";
if (section) section.hidden = true;
return;
}
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
if (!section) return;
const name = user.display_name || user.username || "";
const nameEl = document.getElementById("sidebar-user-name");
if (nameEl) nameEl.textContent = name;
const roleEl = document.getElementById("sidebar-user-role");
if (roleEl) roleEl.textContent = user.role === "admin" ? t("sidebar.user_role_admin") : t("sidebar.user_role_user");
const avatarEl = document.getElementById("sidebar-user-avatar");
if (avatarEl) {
// #113: custom avatar image when set, initials as the fallback.
if (user.avatar) {
avatarEl.textContent = "";
const img = document.createElement("img");
img.src = user.avatar;
img.alt = "";
img.className = "sidebar-user-avatar-img";
avatarEl.appendChild(img);
} else {
avatarEl.textContent = userInitials(name);
}
}
section.hidden = false;
safeCreateIcons();
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
const logoutBtn = document.getElementById("sidebar-user-logout");
if (logoutBtn && !logoutBtn._obsigateBound) {
logoutBtn._obsigateBound = true;
logoutBtn.addEventListener("click", () => this.logout());
}
const profileBtn = document.getElementById("sidebar-user-profile");
if (profileBtn && !profileBtn._obsigateBound) {
profileBtn._obsigateBound = true;
profileBtn.addEventListener("click", () => {
closeMobileSidebar();
const trigger = document.getElementById("profile-open-btn");
if (trigger) trigger.click();
});
}
const adminRow = document.getElementById("admin-menu-row");
if (adminRow) {
@@ -355,6 +412,9 @@ const AuthManager = {
}
},
// Backwards-compatible alias (older callers/tests may use this name).
renderUserMenu() { this.renderUserSection(); },
// ── Initialization ──────────────────────────────────────────────
async checkAuthStatus() {
+227 -6
View File
@@ -767,10 +767,15 @@ function initConfigModal() {
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
closeHeaderMenu();
// BUG-071: reset the TOC to the CSS default (mobile: hidden, desktop:
// visible) like the help modal does on open.
// BUG-071/#114: reset the TOC to the CSS default (mobile: hidden drawer,
// desktop: visible sidebar) like the help modal does on open. Clear the
// stale inline display and the drawer-open class so a previous mobile
// session cannot leave the nav stuck open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
modal.classList.remove("config-toc-open");
var configHamburgerOnOpen = document.getElementById("config-hamburger");
if (configHamburgerOnOpen) configHamburgerOnOpen.classList.remove("active");
renderConfigFilters();
loadConfigFields();
loadDiagnostics();
@@ -786,6 +791,12 @@ function initConfigModal() {
closeBtn.addEventListener("click", closeConfigModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
// #114: a tap on the backdrop (or outside the drawer) first closes the
// TOC drawer; only a second tap closes the whole modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -890,17 +901,19 @@ function initConfigModal() {
});
}
// BUG-071: mobile table of contents. #config-nav shares the .help-nav
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
// reach a section. The header hamburger opens it as a top block; picking
// a section smooth-scrolls inside the modal and collapses it on mobile.
// reach a section. The header hamburger opens it as a left slide-over
// drawer (backdrop via .config-toc-open on the modal); picking a section
// smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
modal.classList.toggle("config-toc-open", !!open && _isConfigMobile());
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
@@ -910,6 +923,14 @@ function initConfigModal() {
_setConfigNav(hidden);
});
}
// #114: close button inside the drawer header.
var configTocClose = document.getElementById("config-toc-close");
if (configTocClose) {
configTocClose.addEventListener("click", function(e) {
e.stopPropagation();
_setConfigNav(false);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
@@ -930,6 +951,11 @@ function initConfigModal() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
// #114: Escape closes the TOC drawer first, then the modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -949,7 +975,13 @@ function initConfigModal() {
function closeConfigModal() {
const modal = document.getElementById("config-modal");
if (modal) modal.classList.remove("active");
if (modal) {
modal.classList.remove("active");
// #114: never leave the drawer-open class (backdrop) behind.
modal.classList.remove("config-toc-open");
const nav = document.getElementById("config-nav");
if (nav) nav.style.display = '';
}
}
// --- Config field helpers ---
@@ -2356,6 +2388,107 @@ function initAboutModal() {
}
// ── Profile ──────────────────────────────────────────────────────────
// #113 — profile picture: pick/import an image, center-crop it to a 256 px
// square JPEG, persist it on the account (PATCH /api/auth/me) and show it in
// the sidebar circle (#sidebar-user-avatar). Initials remain the fallback.
const _AVATAR_MAX_FILE_BYTES = 8 * 1024 * 1024;
const _AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
const _AVATAR_SIZE = 256;
function _profileInitials(name) {
var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
var first = parts[0][0] || "";
var last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
/** Center-crop the picked image to a square and export it as a JPEG data-URL. */
function _resizeAvatarFile(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
try {
var canvas = document.createElement("canvas");
canvas.width = _AVATAR_SIZE;
canvas.height = _AVATAR_SIZE;
var ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
var side = Math.min(img.naturalWidth, img.naturalHeight);
var sx = (img.naturalWidth - side) / 2;
var sy = (img.naturalHeight - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
resolve(canvas.toDataURL("image/jpeg", 0.85));
} catch (err) {
reject(err);
}
};
img.onerror = function () {
URL.revokeObjectURL(url);
reject(new Error("image decode failed"));
};
img.src = url;
});
}
function _profileAvatarError(key) {
var err = document.getElementById("profile-avatar-error");
if (!err) return;
if (!key) {
err.textContent = "";
err.classList.add("hidden");
return;
}
err.textContent = t(key);
err.classList.remove("hidden");
}
/** Paint the settings preview (image or initials) for the given avatar. */
function _renderProfileAvatar(dataUrl) {
var img = document.getElementById("profile-avatar-img");
var initialsEl = document.getElementById("profile-avatar-initials");
var removeBtn = document.getElementById("profile-avatar-remove");
if (!img || !initialsEl) return;
if (dataUrl) {
img.src = dataUrl;
img.hidden = false;
initialsEl.textContent = "";
if (removeBtn) removeBtn.hidden = false;
} else {
img.removeAttribute("src");
img.hidden = true;
var user = AuthManager.getUser() || {};
var name = localStorage.getItem("obsigate-name") || user.display_name || user.username || "";
initialsEl.textContent = _profileInitials(name);
if (removeBtn) removeBtn.hidden = true;
}
}
/** PATCH /api/auth/me with an avatar value (data-URL, or "" to clear). */
async function _patchProfileAvatar(value) {
var headers = { "Content-Type": "application/json" };
var authHeaders = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() : null;
if (authHeaders) headers = Object.assign(headers, authHeaders);
var res = await fetch("/api/auth/me", {
method: "PATCH",
headers: headers,
credentials: "include",
body: JSON.stringify({ avatar: value }),
});
if (!res.ok) {
var detail = "";
try {
detail = (await res.json()).detail || "";
} catch (e) { /* no json body */ }
throw new Error(detail || "HTTP " + res.status);
}
return res.json();
}
function initProfile() {
var langSel = document.getElementById('profile-lang');
var nameInp = document.getElementById('profile-name');
@@ -2393,4 +2526,92 @@ function initProfile() {
});
// Logout button — handled inline in index.html (onclick)
// ── Avatar (#113) ────────────────────────────────────────────────
var avatarField = document.getElementById('profile-avatar-field');
var avatarInput = document.getElementById('profile-avatar-input');
var chooseBtn = document.getElementById('profile-avatar-choose');
var overlayBtn = document.getElementById('profile-avatar-overlay');
var previewBox = document.getElementById('profile-avatar-preview');
var removeBtn = document.getElementById('profile-avatar-remove');
if (!avatarField || !avatarInput || !chooseBtn) return;
// The avatar only exists on a real account — hide it when auth is off.
if (!AuthManager.isAuthEnabled || !AuthManager.isAuthEnabled()) {
avatarField.hidden = true;
return;
}
_renderProfileAvatar((AuthManager.getUser() || {}).avatar || null);
// Refresh from the server so the preview survives a page reload.
fetch('/api/auth/me', { credentials: 'include' })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (user) {
if (!user) return;
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || null);
})
.catch(function () { /* non-bloquant */ });
function openPicker() {
_profileAvatarError(null);
avatarInput.click();
}
chooseBtn.addEventListener('click', openPicker);
if (overlayBtn) overlayBtn.addEventListener('click', openPicker);
if (previewBox) {
previewBox.addEventListener('click', function (e) {
if (e.target.closest('button')) return;
openPicker();
});
}
avatarInput.addEventListener('change', async function () {
var file = avatarInput.files && avatarInput.files[0];
avatarInput.value = ""; // allow re-picking the same file
if (!file) return;
_profileAvatarError(null);
if (_AVATAR_TYPES.indexOf(file.type) === -1) {
_profileAvatarError('config.avatar_invalid_type');
return;
}
if (file.size > _AVATAR_MAX_FILE_BYTES) {
_profileAvatarError('config.avatar_too_large');
return;
}
chooseBtn.disabled = true;
try {
var dataUrl = await _resizeAvatarFile(file);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar upload failed:', err);
} finally {
chooseBtn.disabled = false;
}
});
if (removeBtn) {
removeBtn.addEventListener('click', async function () {
_profileAvatarError(null);
removeBtn.disabled = true;
try {
var user = await _patchProfileAvatar("");
AuthManager.updateCachedUser(user);
_renderProfileAvatar(null);
AuthManager.renderUserSection();
showToast(t('config.avatar_removed'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar removal failed:', err);
} finally {
removeBtn.disabled = false;
}
});
}
}
+36 -4
View File
@@ -700,8 +700,26 @@ export function renderImageViewer(area, data) {
container.appendChild(body);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
const strip = el("div", { class: "image-nav-strip", hidden: true });
container.appendChild(strip);
// #112 — the film scrolls with the mouse wheel and two translucent edge
// arrows; the scrollable strip is wrapped so the arrows stay fixed.
const navWrap = el("div", { class: "image-nav", hidden: true });
const strip = el("div", { class: "image-nav-strip" });
const stripPrev = el("button", {
class: "image-strip-arrow image-strip-arrow-prev",
type: "button",
title: t("viewer.image_strip_prev"),
"aria-label": t("viewer.image_strip_prev"),
}, [icon("chevron-left", 20)]);
const stripNext = el("button", {
class: "image-strip-arrow image-strip-arrow-next",
type: "button",
title: t("viewer.image_strip_next"),
"aria-label": t("viewer.image_strip_next"),
}, [icon("chevron-right", 20)]);
navWrap.appendChild(strip);
navWrap.appendChild(stripPrev);
navWrap.appendChild(stripNext);
container.appendChild(navWrap);
let scale = 1;
let tx = 0;
@@ -807,8 +825,8 @@ export function renderImageViewer(area, data) {
};
const renderStrip = () => {
if (siblings.length < 2) { strip.hidden = true; return; }
strip.hidden = false;
if (siblings.length < 2) { navWrap.hidden = true; return; }
navWrap.hidden = false;
strip.innerHTML = "";
thumbs = siblings.map((s, i) => {
const thumb = el("img", {
@@ -826,6 +844,20 @@ export function renderImageViewer(area, data) {
updateStripActive();
};
// #112 — scroll the film with the wheel (vertical → horizontal) and the
// two overlay arrows; the active image never changes, only the strip view.
const scrollStrip = (dir) => {
strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" });
};
stripPrev.addEventListener("click", () => scrollStrip(-1));
stripNext.addEventListener("click", () => scrollStrip(1));
strip.addEventListener("wheel", (e) => {
if (strip.scrollWidth <= strip.clientWidth) return;
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
strip.scrollLeft += delta;
e.preventDefault();
}, { passive: false });
(async () => {
try {
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
+20 -7
View File
@@ -380,6 +380,15 @@
"config.delete_key": "Delete",
"config.delete_key_confirm": "Delete key",
"config.key_deleted": "Key deleted:",
"config.avatar_label": "Profile picture",
"config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.",
"config.avatar_choose": "Choose an image",
"config.avatar_remove": "Remove picture",
"config.avatar_updated": "Profile picture updated",
"config.avatar_removed": "Profile picture removed",
"config.avatar_too_large": "Image too large (8 MB max)",
"config.avatar_invalid_type": "Unsupported format — use PNG, JPG or WEBP",
"config.avatar_upload_failed": "Failed to save the profile picture",
"config.backups": "Backups",
"config.backups_desc": "Manage automatic file backups.",
"config.client_config": "Client config",
@@ -569,6 +578,7 @@
"config.test": "Test",
"config.timeout_label": "Search timeout (ms)",
"config.title": "Settings",
"config.toc_close": "Close contents",
"config.toc_toggle": "Show contents",
"config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches",
@@ -777,6 +787,7 @@
"header.menu_theme": "Theme",
"header.menu_theme_light": "Light",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profile",
"header.quick_help": "Quick Help",
"header.refresh": "Refresh",
@@ -1106,7 +1117,7 @@
"help.desc_markdown_all": ": Full markdown rendering",
"help.desc_md_render": "Markdown files are rendered with:",
"help.desc_menu_header": ": \"Vault\" dropdown in the Options menu",
"help.desc_menu_options": ": Access to settings, theme, help",
"help.desc_menu_options": ": Access to settings, theme, help and version",
"help.desc_op_ext": ": Filter by file type",
"help.desc_op_path": ": Filter by path",
"help.desc_op_phrase": ": Exact phrase search",
@@ -1309,6 +1320,8 @@
"help.sidebar_filtering": "Sidebar filtering",
"help.sidebar_section": "Sidebar",
"help.sidebar_tabs": "The sidebar has two tabs:",
"help.sidebar_user": "Account",
"help.desc_sidebar_user": ": Avatar, name, role and sign-out at the bottom of the sidebar",
"help.sidebar_toggle_btn": "Sidebar toggle button",
"help.simple": "Simple",
"help.simple_search": "Simple search",
@@ -1619,25 +1632,21 @@
"search.whole_word": "Whole word",
"settings.about": "📦 About",
"settings.ai": "🤖 AI",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "These settings are saved on the server. Some require a restart or reindexing.",
"settings.backend_section": "Backend Settings",
"settings.client_label": "These settings apply immediately on the client side.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Files",
"settings.history_count_desc": "Between 5 and 100 files (saved on server)",
"settings.history_count_label": "Files in history",
"settings.history_section": "Recent History",
"settings.no_restart_badge": "No restart needed",
"settings.profile": "👤 Profile",
"settings.reindex": "Force reindex",
"settings.restart_badge": "Restart required",
"settings.save": "Save",
"settings.search": "Search",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.tabs": "Tabs",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
@@ -1682,6 +1691,8 @@
"sidebar.tab_saved": "Searches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrator",
"sidebar.user_role_user": "User",
"sidebar.vault_context_all": "All vaults",
"sidebar.vault_specific": "Specific vault",
"sync.autocomplete": "Autocomplete",
@@ -1830,6 +1841,8 @@
"viewer.image_next": "Next image",
"viewer.image_open_original": "Open original",
"viewer.image_prev": "Previous image",
"viewer.image_strip_next": "Scroll thumbnails right",
"viewer.image_strip_prev": "Scroll thumbnails left",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom in",
"viewer.image_zoom_out": "Zoom out",
+20 -7
View File
@@ -380,6 +380,15 @@
"config.delete_key": "Supprimer",
"config.delete_key_confirm": "Supprimer la clé",
"config.key_deleted": "Clé supprimée :",
"config.avatar_label": "Photo de profil",
"config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.",
"config.avatar_choose": "Choisir une image",
"config.avatar_remove": "Supprimer la photo",
"config.avatar_updated": "Photo de profil mise à jour",
"config.avatar_removed": "Photo de profil supprimée",
"config.avatar_too_large": "Image trop lourde (8 Mo maximum)",
"config.avatar_invalid_type": "Format non pris en charge — utilisez PNG, JPG ou WEBP",
"config.avatar_upload_failed": "Échec de l'enregistrement de la photo de profil",
"config.backups": "Sauvegardes",
"config.backups_desc": "Gérez les sauvegardes automatiques de vos fichiers.",
"config.client_config": "Configuration client",
@@ -569,6 +578,7 @@
"config.test": "Tester",
"config.timeout_label": "Timeout recherche (ms)",
"config.title": "Configuration",
"config.toc_close": "Fermer le sommaire",
"config.toc_toggle": "Afficher le sommaire",
"config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
@@ -777,6 +787,7 @@
"header.menu_theme": "Thème",
"header.menu_theme_light": "Clair",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profil",
"header.quick_help": "Raccourcis & Astuces",
"header.refresh": "Rafraîchir",
@@ -1106,7 +1117,7 @@
"help.desc_markdown_all": ":\n Rendu complet du markdown",
"help.desc_md_render": "Les fichiers markdown sont rendus avec :",
"help.desc_menu_header": ": Dropdown \"Vault\" dans le menu Options",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide et version",
"help.desc_op_ext": ": Filtrer par type de fichier",
"help.desc_op_path": ": Filtrer par chemin",
"help.desc_op_phrase": ": Recherche de phrase entre guillemets",
@@ -1309,6 +1320,8 @@
"help.sidebar_filtering": "Filtrage de la sidebar",
"help.sidebar_section": "Sidebar (barre latérale)",
"help.sidebar_tabs": "La sidebar est divisée en deux onglets :",
"help.sidebar_user": "Compte",
"help.desc_sidebar_user": ": Avatar, nom, rôle et déconnexion en bas de la sidebar",
"help.sidebar_toggle_btn": "Bouton toggle sidebar",
"help.simple": "Simple",
"help.simple_search": "Recherche simple",
@@ -1619,25 +1632,21 @@
"search.whole_word": "Mot entier",
"settings.about": "📦 À propos",
"settings.ai": "🤖 IA",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "Ces paramètres sont sauvegardés sur le serveur. Certains nécessitent un redémarrage ou une réindexation.",
"settings.backend_section": "Paramètres backend",
"settings.client_label": "Ces paramètres s'appliquent immédiatement côté client.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Explorateur",
"settings.history_count_desc": "Entre 5 et 100 fichiers (sauvegarde sur le serveur)",
"settings.history_count_label": "Nombre de fichiers dans l'historique",
"settings.history_section": "Historique récent",
"settings.no_restart_badge": "Redémarrage non requis",
"settings.profile": "👤 Profil",
"settings.reindex": "Forcer réindexation",
"settings.restart_badge": "Redémarrage requis",
"settings.save": "Sauvegarder",
"settings.search": "Recherche",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.tabs": "Onglets",
"settings.themes": "🎨 Thèmes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Lien copié !",
"share.copy_link": "Copier le lien",
"share.create": "Créer un lien de partage",
@@ -1682,6 +1691,8 @@
"sidebar.tab_saved": "Recherches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrateur",
"sidebar.user_role_user": "Utilisateur",
"sidebar.vault_context_all": "Toutes les vaults",
"sidebar.vault_specific": "Vault spécifique",
"sync.autocomplete": "Auto-complétion",
@@ -1830,6 +1841,8 @@
"viewer.image_next": "Image suivante",
"viewer.image_open_original": "Ouvrir l'original",
"viewer.image_prev": "Image précédente",
"viewer.image_strip_next": "Faire défiler les miniatures vers la droite",
"viewer.image_strip_prev": "Faire défiler les miniatures vers la gauche",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom avant",
"viewer.image_zoom_out": "Zoom arrière",
+490 -48
View File
@@ -2616,6 +2616,60 @@ select {
}
.profile-hint { display: block; font-size: 0.65rem; color: var(--text-muted); margin-top: 4px; }
.profile-saved { font-size: 0.7rem; color: var(--accent-green); margin-left: 10px; }
/* Profile picture picker (#113) */
.profile-avatar-field { margin-bottom: 18px; }
.profile-avatar-label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.profile-avatar-row { display: flex; align-items: center; gap: 16px; }
.profile-avatar-preview {
position: relative;
width: 88px;
height: 88px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #7c3aed));
border: 2px solid var(--border);
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.profile-avatar-preview:hover,
.profile-avatar-preview:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.profile-avatar-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.profile-avatar-initials {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
user-select: none;
}
.profile-avatar-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
margin: 0;
background: rgba(0, 0, 0, 0.45);
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.profile-avatar-preview:hover .profile-avatar-overlay,
.profile-avatar-overlay:focus-visible { opacity: 1; }
.profile-avatar-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; }
.profile-avatar-actions .config-btn-secondary { font-size: 0.75rem; padding: 6px 14px; }
.profile-avatar-remove-btn { background: var(--danger-bg) !important; color: var(--danger) !important; border-color: var(--danger) !important; }
.profile-avatar-error { font-size: 0.7rem; color: var(--danger); margin-top: 8px; }
.profile-avatar-error.hidden { display: none; }
.config-save-btn {
padding: 6px 18px; font-size: 0.75rem; font-weight: 600;
background: var(--accent); color: #fff;
@@ -3110,6 +3164,23 @@ select {
margin-right: 8px;
}
.help-hamburger:hover { color: var(--accent); border-color: var(--accent); }
/* #114: close button for the mobile TOC drawer (hidden on desktop where the
nav is always visible). Scoped to #config-modal so the help modal — which
has no such button — is unaffected. */
.help-toc-close {
display: none;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.help-nav {
width: 260px;
min-width: 260px;
@@ -3815,8 +3886,8 @@ body.resizing-v {
gap: 8px;
}
.user-display-name {
display: none; /* Masquer le nom d'utilisateur sur mobile pour gagner de la place */
.header-right .user-display-name {
display: none; /* Masquer le nom d'utilisateur dans le header sur mobile */
}
.search-wrapper {
@@ -4723,22 +4794,204 @@ body.resizing-v {
}
}
/* BUG-071: Configurations modal — mobile usability (viewport ≤ 768px).
#config-nav shares the .help-nav rule that hides it, but the config modal
had no toggle (unlike the help modal): the header hamburger
(#config-hamburger, same .help-hamburger treatment) reveals it as a
collapsible top block. Two-column grids and fixed-width add-rows are
stacked/wrapped so nothing overflows a 360px viewport. */
/* --- #114: Configurations modal — mobile UX refonte (viewport ≤ 768px).
Builds on BUG-071 (which stacked grids/wrapped rows): the TOC becomes a
left slide-over drawer with a backdrop, the modal goes full-screen with
100dvh, every interactive control gets a ≥44px touch target, inputs are
≥16px to stop iOS auto-zoom, and the backend Save row sticks to the
bottom of its scroll container. The drawer is opened by JS toggling
.config-toc-open on #config-modal (backdrop + nav visibility) plus an
inline display on #config-nav (kept for the display-reset contract). */
@media (max-width: 768px) {
/* TOC as a collapsible top block (JS toggles inline display flex/none,
which wins over the hiding rule); the list scrolls within a capped nav. */
#config-modal #config-nav {
/* Full-screen modal with dynamic viewport height (mobile browser chrome). */
#config-modal {
padding: 0;
align-items: stretch;
}
#config-modal .editor-container {
max-width: 100%;
width: 100%;
height: 100vh;
height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
border-radius: 0;
border: none;
overflow: hidden;
}
/* Hamburger: 44px touch target (shared .help-hamburger rule in the help
modal media query also sets 44px; this scopes it to the config modal
in case rule order changes). */
#config-modal .help-hamburger {
display: inline-flex;
width: 44px;
height: 44px;
min-width: 44px;
}
/* Close button in the TOC drawer header. */
#config-modal .help-toc-close {
display: inline-flex;
align-items: center;
justify-content: center;
}
#config-modal .help-nav-header {
padding: 12px 12px 8px;
gap: 8px;
}
/* Close (X) in the modal header. */
#config-modal .editor-btn {
width: 44px;
height: 44px;
min-width: 44px;
}
/* Anti-zoom: ≥16px font on text fields inside the config modal. Never
target input[type=text] globally — it would clobber .mfa-code-input. */
#config-modal .config-input,
#config-modal .config-select,
#config-modal .help-nav-search,
#config-modal .profile-field .config-input,
#config-modal .profile-field .config-select {
min-height: 44px;
font-size: 16px;
}
#config-modal .config-input--num {
width: 100%;
text-align: left;
}
#config-modal .help-nav-search-wrap {
padding: 0 12px 10px;
}
#config-modal .help-search-clear {
min-width: 44px;
min-height: 44px;
}
#config-modal .help-nav-link {
min-height: 44px;
display: flex;
align-items: center;
box-sizing: border-box;
}
/* Sticky Save row inside #cfg-backend-settings (its scroll container is
#config-scroll.help-content, which is the overflow ancestor). */
#config-modal .config-actions-row {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
margin-bottom: 0;
padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0));
background: var(--bg-primary);
border-top: 1px solid var(--border);
}
#config-modal .config-actions-row .config-btn-save,
#config-modal .config-actions-row .config-btn-secondary,
#config-modal .config-actions-row .config-btn-primary,
#config-modal .config-actions-row .config-btn-danger {
flex: 1 1 auto;
width: 100%;
min-height: 44px;
box-sizing: border-box;
}
#config-modal .config-btn-save,
#config-modal .config-btn-secondary,
#config-modal .config-btn-primary,
#config-modal .config-btn-danger,
#config-modal .config-btn-add,
#config-modal .config-btn-sm,
#config-modal .mfa-link-btn,
#config-modal .theme-action-btn,
#config-modal .profile-avatar-actions .config-btn-secondary,
#config-modal .editor-btn {
min-height: 44px;
box-sizing: border-box;
}
#config-modal .config-btn-sm,
#config-modal .mfa-link-btn,
#config-modal .theme-action-btn {
min-width: 44px;
padding-left: 12px;
padding-right: 12px;
}
/* Profile avatar row: wrap instead of overflowing a 360px viewport. */
#config-modal .profile-avatar-row {
flex-wrap: wrap;
gap: 12px;
}
#config-modal .profile-form {
max-width: 100%;
}
/* MFA: one-column recovery list, wrapping action rows, full-width code
input with a readable letter-spacing (12px overflows 360px). */
#config-modal .mfa-recovery-list {
grid-template-columns: 1fr;
gap: 6px;
}
#config-modal .mfa-verify-section,
#config-modal .mfa-recovery-actions,
#config-modal .mfa-disable-actions {
flex-wrap: wrap;
}
#config-modal .mfa-verify-section .config-input,
#config-modal .mfa-verify-section .config-btn-primary {
flex: 1 1 100%;
width: 100%;
min-width: 0;
max-width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 46vh;
box-sizing: border-box;
}
#config-modal .mfa-recovery-actions .config-btn-secondary,
#config-modal .mfa-disable-actions .config-btn-danger {
flex: 1 1 auto;
min-height: 44px;
box-sizing: border-box;
}
#config-modal .mfa-code-input {
width: 100%;
max-width: 320px;
min-height: 52px;
font-size: 24px;
letter-spacing: 6px;
}
#config-modal .mfa-secret-code {
display: block;
word-break: break-all;
overflow-wrap: anywhere;
}
#config-modal .mfa-code-input-group {
flex-wrap: wrap;
justify-content: flex-start;
}
/* WebAuthn key rows: wrap instead of clipping the remove button. */
#config-modal .webauthn-key-item {
flex-wrap: wrap;
gap: 8px;
}
#config-modal .webauthn-key-label {
flex: 1 1 100%;
min-width: 0;
}
#config-modal .webauthn-key-item .config-btn-sm {
min-height: 44px;
min-width: 44px;
}
#config-modal .hidden-files-add-row {
flex-wrap: wrap;
}
#config-modal .hidden-files-add-row .config-input {
flex: 1 1 100%;
max-width: none;
min-width: 0;
}
#config-modal .config-diag-row {
flex-wrap: wrap;
gap: 4px 8px;
}
/* AI keys sticky footer: clear the mobile toolbar + home indicator. */
#config-modal .ai-keys-footer {
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0));
bottom: 0;
}
/* Two-column grids → single column. */
#config-modal .ai-default-grid,
@@ -4797,6 +5050,61 @@ body.resizing-v {
min-width: 0;
overflow-wrap: anywhere;
}
/* Drawer: left slide-over above a dimming backdrop. Higher specificity
than the generic .help-nav rules (body .help-nav / .help-nav) that hide
the nav on mobile. JS toggles .config-toc-open on #config-modal. */
#config-modal #config-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 88vw);
min-width: 0;
max-width: 88vw;
max-height: 100dvh;
height: 100%;
box-sizing: border-box;
z-index: 40;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
border-bottom: none;
box-shadow: none;
backdrop-filter: none;
display: none;
overflow: hidden;
animation: config-toc-slide-in 200ms ease-out;
}
#config-modal.config-toc-open #config-nav {
display: flex;
max-height: 100dvh;
}
#config-modal .help-nav-list {
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0));
}
/* Backdrop: covers the full modal, sits above the content (z-index auto)
and below the drawer (z-index 40). Clicks on the pseudo-element are
attributed to #config-modal, so the existing e.target === modal handler
closes the drawer first; it also blocks taps reaching the content. */
#config-modal.config-toc-open::before {
content: "";
position: absolute;
inset: 0;
z-index: 35;
background: rgba(0, 0, 0, 0.45);
}
@keyframes config-toc-slide-in {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* Content area gets a bottom pad so the sticky Save row never covers the
last controls while the virtual keyboard is open. */
#config-modal #config-scroll .config-content {
padding-bottom: 80px;
}
}
/* --- Toast notifications --- */
@@ -5765,28 +6073,6 @@ body.resizing-v {
font-size: 0.9rem;
}
.config-btn-add {
padding: 6px 12px;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 150ms ease;
white-space: nowrap;
}
.config-btn-add:hover {
background: color-mix(in srgb, var(--accent) 85%, black);
transform: translateY(-1px);
}
.config-btn-add:active {
transform: translateY(0);
}
/* ---------------------------------------------------------------------------
Utility — hidden class
--------------------------------------------------------------------------- */
@@ -6039,6 +6325,108 @@ body.resizing-v {
color: var(--danger);
}
/* ---------------------------------------------------------------------------
Sidebar account section (#112) — identity + logout pinned at the bottom
--------------------------------------------------------------------------- */
.sidebar-user {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--bg-sidebar);
flex-shrink: 0;
}
.sidebar-user[hidden] {
display: none;
}
.sidebar-user-profile {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
cursor: pointer;
text-align: left;
transition: background 150ms ease, border-color 150ms ease;
}
.sidebar-user-profile:hover {
background: var(--bg-hover);
border-color: var(--border);
}
.sidebar-user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #7c3aed));
}
/* #113 — custom avatar image fills the circle; initials stay the fallback */
.sidebar-user-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
display: block;
}
.sidebar-user-meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.sidebar-user-name {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary);
max-width: none;
}
.sidebar-user-role {
font-size: 0.7rem;
color: var(--text-muted);
}
.sidebar-user-logout {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
}
.sidebar-user-logout:hover {
color: var(--danger);
background: var(--bg-hover);
border-color: var(--border);
}
/* Version row inside the header options menu (#112) */
.menu-list-version {
cursor: default;
border-top: 1px solid var(--border);
margin-top: 4px;
padding-top: 8px;
}
.menu-list-version .version-badge {
margin-left: 4px;
}
/* ---------------------------------------------------------------------------
Admin Panel
--------------------------------------------------------------------------- */
@@ -7751,7 +8139,6 @@ body.popup-mode .content-area {
.webhook-events { color: var(--text-muted); font-size: 0.7rem; }
.webhook-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; }
.config-add-row { display: flex; gap: 8px; margin-top: 8px; }
.config-btn-add { padding: 6px 14px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
/* ── API/MCP tokens UI (#107) ── */
.token-item {
@@ -8350,9 +8737,13 @@ body.desktop-mode .editor-container {
.help-hamburger {
display: inline-flex;
width: 44px;
height: 44px;
min-width: 44px;
}
/* TOC: collapsible block at top (not slide-over) */
/* TOC drawer: collapsible block for the help modal; the config modal
(#114) overrides this with a fixed left drawer + backdrop. */
.help-nav {
position: static;
width: 100%;
@@ -8461,13 +8852,10 @@ body.desktop-mode .editor-container {
}
/* Push content up to avoid toolbar overlap */
.main-layout {
.main-body {
padding-bottom: 64px;
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0));
}
.editor-modal.active ~ .main-layout {
padding-bottom: 0;
}
/* Enable pull-to-refresh on mobile — unblock the overscroll chain */
html {
@@ -9785,18 +10173,66 @@ body.desktop-mode .editor-container {
opacity: 0.55;
}
}
/* #112 — filmstrip wrapper so the scroll arrows stay fixed over the film */
.image-nav {
position: relative;
flex-shrink: 0;
background: var(--surface);
border-top: 1px solid var(--border);
}
.image-nav[hidden] {
display: none;
}
.image-nav-strip {
display: flex;
gap: 6px;
padding: 8px;
padding: 8px 40px;
overflow-x: auto;
background: var(--surface);
border-top: 1px solid var(--border);
scroll-behavior: smooth;
scrollbar-width: thin;
flex-shrink: 0;
}
.image-nav-strip[hidden] {
display: none;
}
/* Translucent arrows that scroll the film left/right (revealed on hover). */
.image-strip-arrow {
position: absolute;
top: 0;
bottom: 0;
width: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
color: var(--text-primary);
opacity: 0;
cursor: pointer;
z-index: 2;
transition: opacity 160ms ease;
}
.image-strip-arrow-prev {
left: 0;
background: linear-gradient(to right, var(--surface) 55%, transparent);
}
.image-strip-arrow-next {
right: 0;
background: linear-gradient(to left, var(--surface) 55%, transparent);
}
.image-nav:hover .image-strip-arrow {
opacity: 0.6;
}
.image-strip-arrow:hover,
.image-strip-arrow:focus-visible {
opacity: 1;
outline: none;
}
@media (hover: none) {
.image-strip-arrow {
opacity: 0.55;
}
}
.image-thumb {
width: 72px;
height: 72px;
@@ -9848,6 +10284,7 @@ body.desktop-mode .editor-container {
z-index: 2000;
background: rgba(0, 0, 0, 0.9);
}
.image-viewer-container.lightbox .image-nav,
.image-viewer-container.lightbox .image-nav-strip {
display: none;
}
@@ -10362,7 +10799,9 @@ body.desktop-mode .editor-container {
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 12px);
}
body.np-active .content-area {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 76px);
/* Toolbar clearance is carried by .main-body (BUG-073): only the dock
zone (76px above the toolbar) remains here — same total as before. */
padding-bottom: 76px;
}
}
@@ -10559,7 +10998,6 @@ body.desktop-mode .editor-container {
}
.mfa-link-btn:hover { opacity: 0.8; }
.mfa-error { color: #e74c3c; font-size: 13px; margin-top: 8px; }
.mfa-recovery-input { width: 200px; font-size: 20px; letter-spacing: 4px; }
/* MFA Settings (Security tab) */
.mfa-status-section { padding: 16px 0; }
@@ -11395,6 +11833,10 @@ body.reading-mode .content-area {
margin: 0 auto;
padding: clamp(20px, 6vw, 48px) clamp(18px, 6vw, 40px) 96px;
}
/* The bottom toolbar is hidden in reading mode: no clearance needed. */
body.reading-mode .main-body {
padding-bottom: 0;
}
body.reading-mode .breadcrumb,
body.reading-mode .file-actions {
display: none;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.20.0",
"version": "2.23.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+73 -8
View File
@@ -1,15 +1,18 @@
/**
* E2E tests for the Configurations modal on mobile (BUG-071).
* E2E tests for the Configurations modal on mobile (BUG-071 + #114).
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js.
*
* Covered:
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
* which is hidden by default on mobile;
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav
* as a left slide-over drawer (fixed positioning), hidden by default;
* - the drawer has a close button (#config-toc-close) and a dimming backdrop
* (tap outside closes the drawer, not the whole modal);
* - picking a TOC entry scrolls to the section, marks the link active and
* collapses the nav;
* collapses the drawer;
* - the modal is full-screen (100dvh) and interactive controls are ≥44px;
* - the modal content does not overflow horizontally at 393px.
*
* Run:
@@ -20,6 +23,7 @@
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
const MIN_TOUCH_TARGET = 44;
async function boot(page) {
await page.goto('/');
@@ -33,8 +37,8 @@ async function openConfigModal(page) {
await expect(page.locator('#config-modal.active')).toBeVisible();
}
test.describe('Configurations modal on mobile (BUG-071)', () => {
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
test.describe('Configurations modal on mobile (BUG-071 + #114)', () => {
test('hamburger reveals the TOC as a fixed drawer', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
@@ -45,9 +49,39 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
// #114: the drawer is a fixed slide-over, not an inline top block.
const position = await page.locator('#config-nav').evaluate(
(el) => window.getComputedStyle(el).position,
);
expect(position).toBe('fixed');
// The modal carries the drawer-open class (backdrop contract).
await expect(page.locator('#config-modal')).toHaveClass(/config-toc-open/);
});
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
test('close button and backdrop dismiss the drawer, not the modal', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// Close button inside the drawer header.
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-toc-close')).toBeVisible();
await page.locator('#config-toc-close').click();
await expect(page.locator('#config-nav')).toBeHidden();
await expect(page.locator('#config-modal.active')).toBeVisible();
// Backdrop tap closes the drawer first; the modal stays open.
// Click near the right edge of the modal (outside the left drawer).
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
await page.locator('#config-modal').click({ position: { x: (viewport?.width ?? 393) - 8, y: 200 } });
await expect(page.locator('#config-nav')).toBeHidden();
await expect(page.locator('#config-modal.active')).toBeVisible();
});
test('picking a section scrolls to it and collapses the drawer', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
@@ -57,7 +91,7 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
await expect(link).toBeVisible();
await link.click();
// Nav collapses on mobile after selection…
// Drawer collapses on mobile after selection…
await expect(page.locator('#config-nav')).toBeHidden();
// …the link is marked active…
await expect(link).toHaveClass(/active/);
@@ -76,6 +110,37 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
.toBeLessThanOrEqual(0);
});
test('modal is full-screen and key controls meet the 44px touch target', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// Full-screen: the modal container fills the viewport.
const container = page.locator('#config-modal .editor-container');
const box = await container.boundingBox();
expect(box).not.toBeNull();
expect(box.width).toBeGreaterThanOrEqual((viewport?.width ?? 0) - 2);
expect(box.height).toBeGreaterThanOrEqual((viewport?.height ?? 0) - 2);
// Header controls (hamburger + close) are ≥44px.
for (const sel of ['#config-hamburger', '#config-close']) {
const b = await page.locator(sel).boundingBox();
expect(b, `${sel} has no bounding box`).not.toBeNull();
expect(b.height, `${sel} height`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
expect(b.width, `${sel} width`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
}
// Backend Save row: buttons are ≥44px tall.
await page.locator('#config-hamburger').click();
await page.locator('#config-nav a[href="#cfg-backend-settings"]').click();
await expect(page.locator('#config-nav')).toBeHidden();
const saveBtn = page.locator('#cfg-save-backend');
await expect(saveBtn).toBeVisible();
const saveBox = await saveBtn.boundingBox();
expect(saveBox).not.toBeNull();
expect(saveBox.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
});
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
+67
View File
@@ -0,0 +1,67 @@
/**
* E2E — Header allégé + section compte en bas de la sidebar (#112).
*
* Le premier test s'exécute partout ; le second (section compte) nécessite
* l'authentification activée et se marque `skip` sinon (job CI e2e, serveur
* local `run-e2e-local` en auth désactivée). Pour le valider manuellement,
* lancer Playwright contre l'instance Docker de test (auth active) :
*
* BASE_URL=http://localhost:2020 OBSIGATE_PASS=test123 \
* npx playwright test tests/e2e/header-sidebar.spec.js --project=chromium-desktop
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function authEnabled(page) {
return page.evaluate(async () => {
try {
const r = await fetch('/api/auth/status');
const d = await r.json();
return !!d.auth_enabled;
} catch {
return false;
}
});
}
test.describe('Header allégé + compte en sidebar (#112)', () => {
test('le header ne porte plus utilisateur/version/déconnexion ; la version est dans le menu Options', async ({ page }) => {
await login(page);
await expect(page.locator('.header-right #user-menu')).toHaveCount(0);
await expect(page.locator('#logout-btn')).toHaveCount(0);
await expect(page.locator('.header-right > .version-badge')).toHaveCount(0);
await page.locator('#header-menu-btn').click();
await expect(page.locator('#header-menu-dropdown #version-badge')).toBeVisible();
});
test('le compte est affiché en bas de la sidebar (auth active)', async ({ page }) => {
await login(page);
test.skip(!(await authEnabled(page)), 'auth disabled in this environment');
await expect(page.locator('#sidebar-user')).toBeVisible();
await expect(page.locator('#sidebar-user-name')).not.toBeEmpty();
await expect(page.locator('#sidebar-user-role')).not.toBeEmpty();
await expect(page.locator('#sidebar-user-logout')).toBeVisible();
});
});
+27
View File
@@ -159,4 +159,31 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
await expect(container).toHaveAttribute('data-inplace', '1');
await expect(page.locator('#content-area .image-counter')).toContainText('/');
});
test('la pellicule est défilable (molette + flèches latérales) sans changer d\'image (#112)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const nav = page.locator('#content-area .image-nav');
const strip = page.locator('#content-area .image-nav-strip');
await expect(strip).toBeVisible({ timeout: 10000 });
await expect(nav).toBeAttached();
// Les deux flèches de défilement existent (translucides, révélées au survol).
const prev = page.locator('#content-area .image-strip-arrow-prev');
const next = page.locator('#content-area .image-strip-arrow-next');
await expect(prev).toBeAttached();
await expect(next).toBeAttached();
await expect
.poll(() => next.evaluate((el) => getComputedStyle(el).opacity))
.not.toBe('1'); // au repos, la flèche n'est pas pleinement visible
// Faire défiler la pellicule ne change PAS l'image courante.
const title = page.locator('#content-area .image-title');
const before = await title.innerText();
await next.hover();
await next.click();
await prev.click();
await expect(title).toHaveText(before);
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* E2E tests for the mobile bottom toolbar clearance (BUG-073).
*
* The fixed bottom bar (#mobile-toolbar, 64px + safe-area) used to cover the
* last pixels of every document/page: the clearance rule targeted the dead
* `.main-layout` selector instead of `.main-body`, so #content-area reached
* behind the bar and the end of the content was unreachable.
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js / config-mobile.spec.js.
*
* Run:
* npx playwright test tests/e2e/mobile-toolbar.spec.js --project=chromium-mobile
* pwsh scripts/run-e2e-local.ps1 --project=chromium-mobile -g "BUG-073"
* (ObsiGate listening on http://localhost:2029, auth disabled)
*/
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
const LONG_DOC = 'ANALYSE_REVIEW.md';
async function boot(page) {
await page.goto('/');
await page
.waitForSelector('#app:not(.hidden)', { timeout: 15000 })
.catch(() => {});
}
/** Wait for the async markdown render, then confirm the area actually scrolls. */
async function openLongDocument(page) {
await page.evaluate((p) => window.TabManager.open('TestVault', p), LONG_DOC);
await expect(page.locator('#content-area .md-content')).toBeAttached({ timeout: 15000 });
await expect
.poll(
() =>
page.evaluate(() => {
const area = document.getElementById('content-area');
return area.scrollHeight > area.clientHeight;
}),
{ timeout: 15000 },
)
.toBe(true);
}
/** Geometry of #content-area vs the fixed #mobile-toolbar bar. */
async function measure(page) {
return page.evaluate(() => {
const area = document.getElementById('content-area').getBoundingClientRect();
const bar = document.getElementById('mobile-toolbar').getBoundingClientRect();
return { areaBottom: area.bottom, barTop: bar.top, barVisible: bar.height > 0 };
});
}
test.describe('Mobile bottom toolbar clearance (BUG-073)', () => {
test('content area ends above the fixed bottom toolbar', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
const toolbar = page.locator('#mobile-toolbar');
await expect(toolbar).toBeVisible();
// Open a long document so the layout is in its real document-view state.
await openLongDocument(page);
await expect(page.locator('#content-area')).toBeVisible();
// The scroll container itself must stop at (or above) the bar — 1px
// tolerance for subpixel rounding.
await expect
.poll(async () => {
const m = await measure(page);
return m.areaBottom - m.barTop;
})
.toBeLessThanOrEqual(1);
});
test('the end of a long document scrolls clear of the toolbar', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openLongDocument(page);
// Scroll to the very end: the last rendered element must sit above the bar.
await page.evaluate(() => {
const area = document.getElementById('content-area');
area.scrollTop = area.scrollHeight;
});
const result = await page.evaluate(() => {
const area = document.getElementById('content-area');
const barTop = document.getElementById('mobile-toolbar').getBoundingClientRect().top;
// Last element of the rendered document (markdown root or content child).
const root = area.querySelector('.md-content') || area.lastElementChild;
let last = root;
if (root && root.lastElementChild) last = root.lastElementChild;
const lastBottom = last ? last.getBoundingClientRect().bottom : area.getBoundingClientRect().bottom;
return { lastBottom, barTop };
});
expect(result.lastBottom).toBeLessThanOrEqual(result.barTop + 1);
});
});
+147 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
* ObsiGate — Configurations modal mobile usability tests (BUG-071 + #114).
*
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
@@ -18,8 +18,16 @@
* style.css must stack/wrap them below 768px with 44px touch targets.
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
* same class of bug as BUG-067 for the help modal).
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"-
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"
* separated) so the toggle carries translated title AND aria-label.
* - #114a: the TOC is a left slide-over drawer (position: fixed) opened by
* .config-toc-open on #config-modal, with a backdrop (::before) and a
* close button (#config-toc-close).
* - #114b: the modal is full-screen (100dvh), inputs are ≥16px anti-zoom
* with 44px min-height, buttons are 44px touch targets, the backend Save
* row is sticky, and the MFA recovery list is single-column on mobile.
* - #114i18n: dead settings.* keys purged, settings.explorer and
* config.toc_close present in FR and EN, settings.tabs translated in FR.
*
* Usage: node tests/frontend/config-mobile.test.mjs
*/
@@ -82,12 +90,17 @@ test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", (
test("config.js — TOC display reset when the modal opens", () => {
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
assert.match(configJs, /modal\.classList\.remove\("config-toc-open"\)/,
"the drawer-open class (backdrop) must be cleared on open");
});
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
test("style.css — config TOC becomes a capped top block on mobile", () => {
test("style.css — config TOC becomes a fixed drawer on mobile", () => {
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/, "the opened TOC must be height-capped so content stays reachable");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?position: fixed/,
"the TOC must be a fixed slide-over drawer on mobile");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/,
"the drawer must be height-capped so content stays reachable");
});
test("style.css — two-column config grids stack on mobile", () => {
@@ -124,6 +137,136 @@ test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
});
// ── #114a: drawer, backdrop, close button ───────────────────────────────────
test("index.html — #config-toc-close exists inside the TOC header", () => {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
assert.match(nav[1], /id="config-toc-close"/, "no #config-toc-close in the TOC — no way to dismiss the drawer");
assert.match(nav[1], /config\.toc_close/, "the close button label must use the i18n key config.toc_close");
});
test("i18n — config.toc_close exists in FR and EN", () => {
assert.ok(fr["config.toc_close"], "fr.json missing config.toc_close");
assert.ok(en["config.toc_close"], "en.json missing config.toc_close");
assert.notEqual(fr["config.toc_close"], "config.toc_close", "FR value must be translated");
assert.notEqual(en["config.toc_close"], "config.toc_close", "EN value must be translated");
});
test("config.js — drawer open class + close button + backdrop + Escape", () => {
assert.match(configJs, /classList\.toggle\("config-toc-open"/,
"_setConfigNav must toggle .config-toc-open on the modal (backdrop + drawer CSS)");
assert.match(configJs, /getElementById\("config-toc-close"\)/,
"no binding on #config-toc-close");
assert.match(configJs, /config-toc-open[\s\S]{0,200}?_setConfigNav\(false\)/,
"a backdrop tap must close the drawer first, not the whole modal");
assert.match(configJs, /Escape[\s\S]{0,200}?config-toc-open[\s\S]{0,120}?_setConfigNav\(false\)/,
"Escape must close the drawer first, then the modal");
assert.match(configJs, /closeConfigModal[\s\S]{0,200}?classList\.remove\("config-toc-open"\)/,
"closeConfigModal must never leave the backdrop class behind");
});
test("style.css — drawer backdrop via .config-toc-open::before", () => {
assert.match(css, /#config-modal\.config-toc-open::before/,
"no backdrop pseudo-element for the open drawer");
assert.match(css, /#config-modal\.config-toc-open::before[\s\S]{0,200}?z-index: 35/,
"the backdrop must sit above the content and below the drawer (z-index 40)");
assert.match(css, /#config-modal\.config-toc-open #config-nav/,
"the open drawer must be forced visible via the modal class");
});
test("style.css — .help-toc-close hidden on desktop, visible in the config drawer", () => {
assert.match(css, /\.help-toc-close\s*\{[^}]*display:\s*none/,
".help-toc-close must be hidden by default (desktop nav is always visible)");
assert.match(css, /#config-modal \.help-toc-close\s*\{[^}]*display:\s*inline-flex/,
"#config-modal .help-toc-close must show on mobile");
});
// ── #114b: full-screen, anti-zoom, touch targets, sticky save ───────────────
test("style.css — config modal goes full-screen with 100dvh on mobile", () => {
assert.match(css, /#config-modal \.editor-container[\s\S]{0,300}?100dvh/,
"the modal must fill the dynamic viewport (mobile browser chrome)");
assert.match(css, /#config-modal[\s\S]{0,200}?padding:\s*0/,
"the modal must drop its outer padding on mobile");
});
test("style.css — inputs are ≥16px with 44px min-height (anti-zoom iOS)", () => {
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?font-size:\s*16px/,
"config inputs must be ≥16px to stop iOS auto-zoom");
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?min-height:\s*44px/,
"config inputs need a 44px touch target");
assert.match(css, /#config-modal \.config-select[\s\S]{0,200}?font-size:\s*16px/,
"config selects must be ≥16px to stop iOS auto-zoom");
});
test("style.css — primary buttons are 44px touch targets", () => {
// The shared button block lists many selectors; match from the block start
// (first #config-modal .config-btn-save) to the min-height declaration.
const buttonBlock = css.match(/#config-modal \.config-btn-save,[\s\S]{0,600}?\{[^}]*min-height:\s*44px/);
assert.ok(buttonBlock, ".config-btn-save needs a 44px touch target");
for (const cls of ["config-btn-secondary", "config-btn-danger", "config-btn-sm"]) {
assert.match(css, new RegExp(`#config-modal \\.${cls}`), `#${cls} has no #114 mobile rule`);
}
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,400}?min-height:\s*44px/,
"buttons inside the sticky Save row need a 44px touch target");
});
test("style.css — backend Save row is sticky at the bottom of its section", () => {
assert.match(css, /#config-modal \.config-actions-row\s*\{[^}]*position:\s*sticky/,
"the Save row must stick to the bottom while scrolling the backend section");
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?flex-direction:\s*column/,
"the Save row must stack vertically on mobile");
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?env\(safe-area-inset-bottom/,
"the sticky Save row must clear the home indicator");
});
test("style.css — MFA recovery list single-column + verify section wraps", () => {
assert.match(css, /#config-modal \.mfa-recovery-list\s*\{[^}]*grid-template-columns:\s*1fr/,
"recovery codes must be a single column on mobile");
assert.match(css, /#config-modal \.mfa-verify-section[\s\S]{0,200}?flex-wrap:\s*wrap/,
"the MFA verify row must wrap instead of overflowing");
assert.match(css, /#config-modal \.mfa-code-input[\s\S]{0,300}?width:\s*100%/,
"the MFA code input must be full-width on mobile");
});
test("index.html — .config-actions-row lives inside #cfg-backend-settings", () => {
const section = indexHtml.match(/<section[^>]*id="cfg-backend-settings"[\s\S]*?<\/section>/);
assert.ok(section, "#cfg-backend-settings section not found");
assert.match(section[0], /class="config-actions-row"/,
".config-actions-row must be inside the backend section (sticky footer contract)");
});
// ── #114i18n: dead keys purged, new keys added ──────────────────────────────
test("i18n — dead settings.* keys are purged from FR and EN", () => {
const dead = ["settings.backend", "settings.backend_hint", "settings.restart_badge",
"settings.save", "settings.plugins"];
for (const key of dead) {
assert.equal(fr[key], undefined, `fr.json still has dead key ${key}`);
assert.equal(en[key], undefined, `en.json still has dead key ${key}`);
}
});
test("i18n — settings.explorer exists in FR and EN", () => {
assert.ok(fr["settings.explorer"], "fr.json missing settings.explorer");
assert.ok(en["settings.explorer"], "en.json missing settings.explorer");
assert.notEqual(fr["settings.explorer"], "settings.explorer", "FR value must be translated");
assert.notEqual(en["settings.explorer"], "settings.explorer", "EN value must be translated");
});
test("i18n — settings.tabs is translated in FR (was the English word \"Tabs\")", () => {
assert.equal(fr["settings.tabs"], "Onglets", "fr.json settings.tabs must be French");
assert.equal(en["settings.tabs"], "Tabs", "en.json settings.tabs must stay English");
});
test("index.html — #mt-explorer label carries data-i18n=settings.explorer", () => {
assert.match(indexHtml, /id="mt-explorer"[\s\S]{0,400}?data-i18n="settings\.explorer"/,
"the explorer toolbar button must be translated via settings.explorer");
});
test("index.html — no dead container #plugins-settings-container", () => {
assert.ok(!indexHtml.includes("plugins-settings-container"),
"#plugins-settings-container was a dead div — plugins render into #cfg-plugins");
});
if (process.exitCode) {
console.error("\nConfig mobile tests FAILED");
} else {
+15
View File
@@ -122,6 +122,21 @@ test("image viewer navigates between siblings in place (#111)", () => {
assert.match(viewer, /const _imageDirCache = new Map\(\)/);
});
// #112 — the filmstrip scrolls horizontally (mouse wheel + two edge arrows).
test("image viewer filmstrip scrolls with the wheel and edge arrows (#112)", () => {
assert.match(viewer, /const navWrap = el\("div", \{ class: "image-nav", hidden: true \}\)/);
assert.match(viewer, /image-strip-arrow-prev/);
assert.match(viewer, /image-strip-arrow-next/);
assert.match(viewer, /strip\.addEventListener\("wheel"/);
assert.match(viewer, /strip\.scrollBy\(\{ left: dir/);
assert.match(viewer, /navWrap\.hidden = true/);
assert.match(viewer, /navWrap\.hidden = false/);
assert.match(css, /\.image-nav\s*\{[\s\S]*?position:\s*relative/);
assert.match(css, /\.image-strip-arrow\s*\{/);
assert.match(css, /\.image-strip-arrow-prev\s*\{\s*left:\s*0/);
assert.match(css, /\.image-strip-arrow-next\s*\{\s*right:\s*0/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-stage\s*\{/);
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* ObsiGate — Mobile bottom toolbar clearance non-regression tests (BUG-073).
*
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
* - BUG-073: the fixed bottom toolbar (#mobile-toolbar, 64px + safe-area)
* covered the last pixels of every document/page because the clearance
* rule in the ≤768px media block targeted `.main-layout` — a class that
* does not exist in index.html (the real layout container is
* `.main-body`). The dead selector reserved no space, so #content-area
* extended behind the bar and the end of the content could never be
* scrolled clear of it.
* - The clearance must live on `.main-body` inside the mobile media block
* (desktop keeps no padding), the dead `.main-layout` rules must be gone,
* reading mode (toolbar hidden) must reset the padding, and the
* now-playing rule must not double-count the toolbar zone.
*
* Usage: node tests/frontend/mobile-toolbar.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + err.message);
process.exitCode = 1;
}
}
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
function mobileBlockContaining(needle) {
const marker = "@media (max-width: 768px)";
let idx = 0;
while ((idx = css.indexOf(marker, idx)) !== -1) {
const open = css.indexOf("{", idx);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === "{") depth++;
else if (css[end] === "}") {
depth--;
if (depth === 0) break;
}
}
const body = css.slice(open + 1, end);
if (body.includes(needle)) return body;
idx = end + 1;
}
return null;
}
// ── The layout container the clearance must target exists in the DOM ───────
test("index.html — layout container .main-body and #mobile-toolbar both exist", () => {
assert.match(indexHtml, /class="main-body"/, "index.html must define the .main-body layout container");
assert.match(indexHtml, /class="mobile-toolbar"/, "index.html must define the .mobile-toolbar bar");
});
// ── BUG-073: clearance inside the mobile media block ────────────────────────
const toolbarBlock = mobileBlockContaining(".mobile-toolbar");
test("style.css — a ≤768px media block styles .mobile-toolbar", () => {
assert.ok(toolbarBlock, "no @media (max-width: 768px) block contains .mobile-toolbar");
});
test("style.css — mobile clearance targets .main-body (64px + safe-area)", () => {
assert.ok(toolbarBlock, "toolbar media block not found");
assert.match(
toolbarBlock,
/\.main-body \{[^}]*padding-bottom:[^}]*calc\(64px \+ env\(safe-area-inset-bottom/,
"the toolbar clearance must pad .main-body inside the mobile block (BUG-073: .main-layout does not exist in index.html)",
);
});
test("style.css — dead .main-layout clearance rules are gone", () => {
assert.ok(toolbarBlock, "toolbar media block not found");
assert.doesNotMatch(
toolbarBlock,
/\.main-layout \{[^}]*padding-bottom/,
".main-layout clearance is a dead selector in index.html (BUG-073)",
);
assert.doesNotMatch(
toolbarBlock,
/\.editor-modal\.active ~ \.main-layout/,
"the sibling clearance reset targeted a selector that never matches (BUG-073)",
);
});
test("style.css — desktop .main-body carries no toolbar padding", () => {
const base = css.match(/\.main-body \{[^}]*\}/);
assert.ok(base, ".main-body base rule not found");
assert.doesNotMatch(base[0], /padding-bottom/, "toolbar clearance must stay mobile-only");
});
// ── Companion rules ─────────────────────────────────────────────────────────
test("style.css — reading mode resets the clearance (toolbar hidden)", () => {
assert.match(
css,
/body\.reading-mode \.main-body \{[^}]*padding-bottom: 0/,
"reading mode hides #mobile-toolbar: .main-body must release the padding",
);
});
test("style.css — np-active keeps only the dock zone (no toolbar double-count)", () => {
assert.match(
css,
/body\.np-active \.content-area \{[^}]*padding-bottom: 76px/,
"toolbar clearance now comes from .main-body: np-active must pad the dock zone only (76px)",
);
assert.doesNotMatch(
css,
/body\.np-active \.content-area \{[^}]*calc\(64px \+ env\(safe-area-inset-bottom[^}]*\+ 76px/,
"the old formula double-counted the toolbar zone already cleared by .main-body",
);
});
if (process.exitCode) {
console.error("\nMobile toolbar tests FAILED");
} else {
console.log("\nAll mobile toolbar tests passed.");
}
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* ObsiGate — Settings section order + profile avatar (#113).
*
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
* - Order: the #config-nav TOC and the page sections must list the same
* anchors in the same order, with #cfg-profile first and #cfg-about last
* (regression guard for the reorganization).
* - Avatar UI: the Profil section must carry the picker (preview, file
* input, choose/remove buttons), config.js the resize/PATCH flow, auth.js
* the sidebar image rendering, style.css the preview/img rules, and the
* i18n keys must exist in FR and EN.
* - Backend: backend/auth/router.py must validate the avatar data-URL.
*
* Usage: node tests/frontend/settings-order-avatar.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
const authJs = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const routerPy = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
function test(label, fn) {
try {
fn();
console.log(" ✓ " + label);
} catch (err) {
console.error(" ✗ " + label + "\n " + err.message);
process.exitCode = 1;
}
}
function tocHrefs() {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
return [...nav[1].matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
}
// ── #113a: order — TOC and page in sync ─────────────────────────────────────
test("index.html — #cfg-profile is the first TOC entry, #cfg-about the last", () => {
const hrefs = tocHrefs();
assert.ok(hrefs.length >= 10, "TOC must list the settings sections");
assert.equal(hrefs[0], "cfg-profile", "Profile must come first");
assert.equal(hrefs[hrefs.length - 1], "cfg-about", "About must come last");
});
test("index.html — page sections appear in the exact TOC order", () => {
const hrefs = tocHrefs();
const start = indexHtml.indexOf('id="config-scroll"');
assert.ok(start > 0, "#config-scroll not found");
const region = indexHtml.slice(start);
let prev = -1;
for (const id of hrefs) {
const pos = region.indexOf(`id="${id}"`);
assert.ok(pos > 0, `section #${id} missing from the page`);
assert.ok(pos > prev, `section #${id} is out of TOC order`);
prev = pos;
}
});
test("index.html — TOC has no dead anchors and page has no orphan cfg sections", () => {
const hrefs = tocHrefs();
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
assert.deepEqual(missing, [], `dead TOC anchors: ${missing.join(", ")}`);
// Every cfg-* id declared on a <section> must be reachable from the TOC
// (form-control ids like #cfg-debounce are not sections).
const start = indexHtml.indexOf('id="config-scroll"');
const region = indexHtml.slice(start, indexHtml.indexOf("Graph View Modal"));
const pageIds = [...region.matchAll(/<section[^>]*id="(cfg-[^"]+)"/g)].map((m) => m[1]);
const orphans = [...new Set(pageIds)].filter((id) => !hrefs.includes(id));
assert.deepEqual(orphans, [], `page sections missing from the TOC: ${orphans.join(", ")}`);
});
// ── #113b: avatar UI in the Profil section ──────────────────────────────────
test("index.html — Profil section carries the avatar picker", () => {
const section = indexHtml.match(
/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/,
);
assert.ok(section, "#cfg-profile section not found");
const html = section[0];
for (const id of [
"profile-avatar-field",
"profile-avatar-preview",
"profile-avatar-img",
"profile-avatar-initials",
"profile-avatar-input",
"profile-avatar-choose",
"profile-avatar-remove",
"profile-avatar-error",
]) {
assert.match(html, new RegExp(`id="${id}"`), `#${id} missing from #cfg-profile`);
}
assert.match(html, /accept="image\/png,image\/jpeg,image\/webp"/,
"the file input must restrict accepted image types");
});
test("config.js — avatar flow: type/size guards, resize, PATCH, sidebar refresh", () => {
assert.match(configJs, /_AVATAR_TYPES/, "accepted MIME types must be declared");
assert.match(configJs, /image\/png[\s\S]{0,80}image\/jpeg[\s\S]{0,80}image\/webp/,
"PNG/JPEG/WebP must be the accepted types");
assert.match(configJs, /_AVATAR_MAX_FILE_BYTES/, "raw file size guard missing");
assert.match(configJs, /_resizeAvatarFile/, "client-side resize helper missing");
assert.match(configJs, /toDataURL\(["']image\/jpeg["']/, "resize must export a JPEG data-URL");
assert.match(configJs, /method:\s*["']PATCH["'][\s\S]{0,200}avatar/,
"avatar must be persisted via PATCH /api/auth/me");
assert.match(configJs, /avatar:\s*value|JSON\.stringify\(\{\s*avatar/,
"the PATCH body must carry the avatar field");
assert.match(configJs, /renderUserSection\(\)/,
"the sidebar must be re-rendered after an avatar change");
assert.match(configJs, /config\.avatar_invalid_type/, "invalid-type error missing");
assert.match(configJs, /config\.avatar_too_large/, "too-large error missing");
});
test("auth.js — sidebar circle renders the avatar image, initials as fallback", () => {
assert.match(authJs, /user\.avatar/, "renderUserSection must read user.avatar");
assert.match(authJs, /sidebar-user-avatar-img/, "the <img> must carry its CSS class");
assert.match(authJs, /userInitials\(name\)/, "initials fallback removed");
assert.match(authJs, /updateCachedUser/, "cached-user merge helper missing");
assert.match(authJs, /isAuthEnabled\(/, "public auth-status helper missing");
});
test("style.css — preview circle and sidebar image rules exist", () => {
assert.match(css, /\.profile-avatar-preview\s*\{/, "no .profile-avatar-preview rule");
assert.match(css, /\.profile-avatar-overlay\s*\{/, "no hover overlay rule");
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
});
// ── #113c: i18n + backend ───────────────────────────────────────────────────
test("i18n — avatar keys exist in FR and EN", () => {
const keys = [
"config.avatar_label",
"config.avatar_hint",
"config.avatar_choose",
"config.avatar_remove",
"config.avatar_updated",
"config.avatar_removed",
"config.avatar_too_large",
"config.avatar_invalid_type",
"config.avatar_upload_failed",
];
for (const key of keys) {
assert.ok(fr[key], `fr.json missing ${key}`);
assert.ok(en[key], `en.json missing ${key}`);
assert.notEqual(fr[key], key, `fr.json ${key} is untranslated`);
assert.notEqual(en[key], key, `en.json ${key} is untranslated`);
}
});
test("backend — /api/auth/me validates the avatar data-URL", () => {
assert.match(routerPy, /_AVATAR_DATA_URL_RE/, "data-URL regex missing");
assert.match(routerPy, /def _validate_avatar/, "validation helper missing");
assert.match(routerPy, /image\/\(\?:png\|jpeg\|webp\)/, "only PNG/JPEG/WebP allowed (no SVG)");
assert.match(routerPy, /_AVATAR_MAX_CHARS/, "size cap missing");
assert.match(routerPy, /"avatar": current_user\.get\("avatar"\)/, "GET /me must return the avatar");
assert.match(routerPy, /avatar: str \| None = None/, "UpdateMeRequest must expose avatar");
assert.match(routerPy, /updates\["avatar"\] = _validate_avatar/, "PATCH /me must validate before store");
});
if (process.exitCode) {
console.error("\nSettings order & avatar tests FAILED");
} else {
console.log("\nAll settings order & avatar tests passed.");
}
+45
View File
@@ -240,6 +240,50 @@ function testAIFabModuleExports() {
console.log(' ✓ ai-fab.js exports initAIFab');
}
// ── Test header trimming + sidebar account section (#112) ──────────────────
const AUTH_PATH = join(JS_DIR, 'auth.js');
const STYLE_PATH = join(FRONTEND_DIR, 'style.css');
function testSidebarUserSection() {
const html = readFileSync(INDEX_HTML, 'utf-8');
const auth = readFileSync(AUTH_PATH, 'utf-8');
const css = readFileSync(STYLE_PATH, 'utf-8');
// The header no longer carries the user block / version badge…
assert.doesNotMatch(html, /id="user-menu"/, 'header #user-menu must be removed');
assert.doesNotMatch(html, /id="logout-btn"/, 'header sign-out button must be removed');
const headerRight = html.slice(
html.indexOf('class="header-right"'),
html.indexOf('class="header-menu"'),
);
assert.doesNotMatch(headerRight, /version-badge/, 'version badge must leave the header-right');
assert.doesNotMatch(headerRight, /user-menu/, 'user block must leave the header-right');
// …the version lives in the options dropdown…
assert.match(html, /class="menu-list-row menu-list-version"[\s\S]*?id="version-badge"/);
// …and the account section sits at the bottom of the sidebar.
assert.match(html, /id="sidebar-user"/);
assert.match(html, /id="sidebar-user-logout"/);
assert.match(html, /id="sidebar-user-name"/);
assert.match(html, /id="sidebar-user-avatar"/);
// auth.js drives it.
assert.match(auth, /renderUserSection\(\)/);
assert.match(auth, /getElementById\("sidebar-user"\)/);
// styles + i18n exist in FR and EN.
assert.match(css, /\.sidebar-user\s*\{/);
assert.match(css, /\.menu-list-version\s*\{/);
for (const lang of ['fr', 'en']) {
const locale = JSON.parse(readFileSync(join(FRONTEND_DIR, 'locales', `${lang}.json`), 'utf-8'));
for (const key of ['header.menu_version', 'sidebar.user_role_admin', 'sidebar.user_role_user']) {
assert.ok(locale[key], `${lang}: missing i18n key ${key}`);
}
}
console.log(' ✓ header trimmed + sidebar account section (#112)');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -255,6 +299,7 @@ async function main() {
['ai-fab module exists', testAIFabModuleExists],
['ai-fab module exports', testAIFabModuleExports],
['config TOC icons', testConfigTocIcons],
['sidebar user section', testSidebarUserSection],
];
for (const [name, fn] of tests) {
+95 -1
View File
@@ -308,4 +308,98 @@ class TestAdmin:
resp = auth_client.post("/api/auth/logout", headers={
"Authorization": f"Bearer {token}",
})
assert resp.status_code == 200
assert resp.status_code == 200
# ═══════════════════════════════════════════════════════════════════
# Profile avatar (#113)
# ═══════════════════════════════════════════════════════════════════
# 1×1 transparent PNG.
TINY_PNG = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
"z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
class TestAvatar:
def _login(self, client):
resp = client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
return resp.json()["access_token"]
def _headers(self, token):
return {"Authorization": f"Bearer {token}"}
def test_me_returns_avatar_field(self, auth_client):
"""GET /api/auth/me exposes the avatar key (None by default)."""
token = self._login(auth_client)
resp = auth_client.get("/api/auth/me", headers=self._headers(token))
assert resp.status_code == 200
assert "avatar" in resp.json()
assert resp.json()["avatar"] is None
def test_patch_me_sets_avatar(self, auth_client):
"""A valid PNG data-URL is persisted and returned by GET /me."""
token = self._login(auth_client)
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": TINY_PNG})
assert resp.status_code == 200
assert resp.json()["avatar"] == TINY_PNG
me = auth_client.get("/api/auth/me", headers=self._headers(token)).json()
assert me["avatar"] == TINY_PNG
def test_patch_me_avatar_empty_string_clears(self, auth_client):
"""Sending an empty string removes the avatar."""
token = self._login(auth_client)
auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": TINY_PNG})
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": ""})
assert resp.status_code == 200
assert resp.json()["avatar"] is None
def test_patch_me_avatar_rejects_svg(self, auth_client):
"""SVG data-URLs are refused (XSS surface)."""
token = self._login(auth_client)
svg = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": svg})
assert resp.status_code == 400
def test_patch_me_avatar_rejects_non_image_payload(self, auth_client):
"""A PNG data-URL header over non-image bytes is refused."""
token = self._login(auth_client)
fake = "data:image/png;base64,aGVsbG8gd29ybGQ=" # "hello world"
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": fake})
assert resp.status_code == 400
def test_patch_me_avatar_rejects_too_large(self, auth_client):
"""Payloads above the size cap are refused before any decode."""
token = self._login(auth_client)
huge = "data:image/png;base64," + "A" * 500_000
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": huge})
assert resp.status_code == 400
def test_patch_me_avatar_rejects_invalid_base64(self, auth_client):
"""Malformed base64 inside a well-formed data-URL is refused."""
token = self._login(auth_client)
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": "data:image/png;base64,@@not-base64@@"})
assert resp.status_code == 400
def test_login_user_payload_includes_avatar(self, auth_client):
"""The login response user object carries the avatar for the sidebar."""
token = self._login(auth_client)
auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": TINY_PNG})
# Re-login to get a fresh user payload.
resp = auth_client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
assert resp.json()["user"]["avatar"] == TINY_PNG