Compare commits

...
2 Commits
Author SHA1 Message Date
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
bruno b926f01b85 feat: visionneuse d'images - navigation fluide, image ajustee au cadre, fleches laterales et pellicule persistante #111
CI / lint (push) Successful in 2m2s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m23s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 13m27s
2026-09-23 14:37:41 -04:00
22 changed files with 1009 additions and 109 deletions
+1
View File
@@ -38,6 +38,7 @@ 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
+47 -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.19.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.21.0**.
---
@@ -14,6 +14,52 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
- **#111 — visionneuse d'images : flèches latérales translucides.** Deux boutons
superposés le long des bords du cadre passent à l'image précédente/suivante ;
quasi invisibles au repos, ils se révèlent au survol (et restent visibles sur
les appareils tactiles). Un compteur `n / total` complète la barre d'outils.
### Modifié
- **#111 — navigation d'images fluide.** Changer d'image se fait désormais **en
place** (`showSibling` remplace le `src` du `<img>`) : plus de rechargement de
la vue ni de nouvel appel `/api/browse` à chaque flèche, **même dans un dossier
contenant beaucoup d'images**. La liste du dossier est mise en cache (TTL court)
et les images voisines sont préchargées ; la **pellicule de miniatures reste
visible** et la vignette active est simplement re-marquée.
- **#111 — image toujours ajustée au cadre.** La visionneuse occupe tout l'espace
disponible (zone de contenu en flex, sans défilement de page) et l'image est
systématiquement redimensionnée dans son cadre, y compris panneau
« Métadonnées » ouvert.
---
## [2.19.2] — 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.19.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.21.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.19.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.21.0).
---
*Projet : ObsiGate | Version : 2.19.2 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.21.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.19.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.21.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.19.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.21.0).
---
*Project: ObsiGate | Version: 2.19.2 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.21.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.19.2
2.21.0
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.19.2"
version = "2.21.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.19.2"
version = "2.21.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.19.2",
"version": "2.21.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+4 -4
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.19.2 | **Dernière mise à jour :** 2026-09-23
> **Version :** 2.21.0 | **Dernière mise à jour :** 2026-09-23
> **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,8 +40,6 @@
- [ ] **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
@@ -185,6 +183,8 @@
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
| 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) |
---
@@ -192,7 +192,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–110, #92 | ~130 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–112, #92 | ~130 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.
+80
View File
@@ -0,0 +1,80 @@
# #111 — Visionneuse d'images : navigation fluide
> **Version livrée :** 2.20.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/style.css`, i18n FR/EN).
> **Améliore :** [#108](./image-support.md) (visionneuse livrée en 2.17.0).
## Contexte
La visionneuse d'images de #108 fonctionnait mais restait perfectible sur l'usage
quotidien :
- certaines images s'affichaient **plus grandes que le cadre** de présentation ;
- changer d'image (←/→, flèches, vignette) appelait `openFile` → `renderFile` →
`renderImageViewer`, soit **un rechargement complet** de la vue **et** un
nouvel appel `/api/browse` à *chaque* image — sensible dès qu'un dossier en
contient beaucoup ;
- la **pellicule de miniatures disparaissait** le temps du re-rendu ;
- aucune zone de clic latérale ne permettait de changer d'image.
## Ce qui a été livré
### A. Ajustement au cadre
- La zone de contenu devient un conteneur **flex** dédié à la visionneuse
(`.content-area:has(> .image-viewer-container) { padding: 0; overflow: hidden }`) :
plus de défilement de page, la visionneuse occupe tout l'espace disponible.
- `.image-stage` gagne `min-height: 0`, un `padding` de 16 px (cadre) et
`box-sizing: border-box` ; `.image-main` conserve `max-width/height: 100%`
avec `width/height: auto`. L'image est donc **toujours redimensionnée dans
l'espace disponible**, y compris quand le panneau « Métadonnées » s'ouvre
(la scène se réduit, l'image suit).
### B. Navigation « en place » (performance)
- `renderImageViewer` ne se contente plus de rendre une image : il gère un état
mutable (`currentPath`, `currentTitle`, `imgUrl`, `currentMeta`) et expose
`showSibling(index)` qui **remplace le `src` du `<img>`** sans reconstruire le
DOM. Les flèches, le clavier ←/→ et les miniatures passent tous par là.
- Conséquences : plus de `openFile`, plus de re-rendu, **plus de refetch du
fichier ni de `/api/browse`** à chaque image.
- **Cache annuaire** `_imageDirCache` (`Map`, TTL 15 s) : la liste des images
d'un dossier n'est récupérée qu'une fois par courte fenêtre.
- **Préchargement** des images voisines (`new Image()`), avec un `Set` pour
éviter les doublons.
### C. Pellicule persistante
- La pellicule de miniatures n'est plus recréée à chaque navigation ; la
vignette active est simplement re-marquée (`.active`) et **amenée dans la vue
par défilement horizontal du film uniquement** (jamais la page).
- Miniatures en `loading="lazy"` + `decoding="async"`.
### D. Flèches latérales translucides
- Deux boutons superposés `.image-nav-arrow` (prev/next) longent les bords du
cadre, avec une icône `chevron` et une ombre portée pour rester lisibles sur
toute image.
- Opacité quasi nulle au repos, révélée au **survol du cadre** (`0.4`) puis du
bouton (`1`, avec dégradé sombre). Toujours visibles (opacité moyenne) sur les
appareils tactiles (`@media (hover: none)`).
- Le `pointerdown`/`dblclick` des flèches n'est pas propagé à la scène : le
pan (glisser) et le double-clic de réinitialisation du zoom restent intacts.
- Un **compteur** `n / total` est ajouté à la barre d'outils.
## Tests
- `tests/frontend/image-viewer.test.mjs` : helpers purs inchangés + vérifications
statiques de la navigation en place (`showSibling`, absence de
`_imageViewerNavPending`, cache annuaire), des flèches et du CSS
(`:has(> .image-viewer-container)`, `max-width/height`, `opacity`).
- `tests/e2e/image-viewer.spec.js` (+1) : image contenue dans le cadre, flèche
superposée révélée au survol, titre mis à jour **sans recréer le conteneur**
(marqueur `data-inplace`), pellicule toujours visible et compteur affiché.
## Limitations connues
- Le cache annuaire a un TTL court : une image ajoutée puis ouverte dans les
quelques secondes peut ne pas apparaître tout de suite dans la pellicule.
- Les flèches latérales n'apparaissent pas en mode lightbox plein écran
(navigation clavier ←/→ et pellicule masquée conservées).
+57 -19
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 -->
@@ -3293,6 +3327,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>
+45 -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
// ---------------------------------------------------------------------------
@@ -323,22 +332,46 @@ 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) 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 +388,9 @@ const AuthManager = {
}
},
// Backwards-compatible alias (older callers/tests may use this name).
renderUserMenu() { this.renderUserSection(); },
// ── Initialization ──────────────────────────────────────────────
async checkAuthStatus() {
+214 -46
View File
@@ -548,13 +548,10 @@ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
// BUG-072 — the lightbox and metadata panel states must survive the re-render
// triggered by ←/→ navigation (openFile → renderFile → renderImageViewer).
// Reset as soon as a non-image file is rendered.
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
// is rendered. #111 — image→image navigation now happens **in place**, so the
// viewer (and these states) is no longer destroyed between siblings.
const _imageViewerState = { lightbox: false, meta: false };
// Set right before an image→image navigation (←/→ / filmstrip click) so
// renderFile knows the state above must be carried over instead of reset.
let _imageViewerNavPending = false;
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
@@ -578,6 +575,40 @@ function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
// #111 — directory listing cache. Opening / switching to another image must not
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
const _imageDirCache = new Map();
const IMAGE_DIR_CACHE_TTL = 15000;
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
async function listImageSiblings(vault, dir) {
const key = `${vault}\u0000${dir}`;
const cached = _imageDirCache.get(key);
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
_imageDirCache.set(key, { items, ts: Date.now() });
return items;
}
const IMAGE_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".bmp": "image/bmp",
".ico": "image/x-icon",
};
/** Best-effort MIME type for an image path (metadata panel). */
function imageMimeFor(path) {
const lower = (path || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
}
function formatBytes(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} o`;
@@ -592,20 +623,30 @@ function formatBytes(n) {
*/
export function renderImageViewer(area, data) {
const vault = data.vault;
const path = data.path;
const fileName = (path || "").split("/").pop();
const imgUrl = buildImageUrl(vault, path);
// #111 — mutable current-image state: navigating swaps these in place instead
// of re-rendering the whole viewer (which used to refetch the file + listing).
let currentPath = data.path;
let currentTitle = data.title || (currentPath || "").split("/").pop();
let imgUrl = buildImageUrl(vault, currentPath);
let currentMeta = {
image_mime: data.image_mime || imageMimeFor(currentPath),
size_bytes: data.size_bytes,
modified: data.modified,
};
area.innerHTML = "";
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
// ── Toolbar ────────────────────────────────────────────────────────────
const toolbar = el("div", { class: "image-toolbar" });
toolbar.appendChild(el("span", { class: "image-title", title: path }, [
document.createTextNode(data.title || fileName),
]));
const titleEl = el("span", { class: "image-title", title: currentPath }, [
document.createTextNode(currentTitle),
]);
toolbar.appendChild(titleEl);
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]);
toolbar.appendChild(counter);
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
toolbar.appendChild(zoomBadge);
@@ -630,8 +671,26 @@ export function renderImageViewer(area, data) {
// ── Body: image stage + metadata sidebar (right) ───────────────────────
const body = el("div", { class: "image-viewer-body" });
const stage = el("div", { class: "image-stage" });
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" });
stage.appendChild(img);
// ── Overlay arrows along the frame edges (#111) ────────────────────────
const prevArrow = el("button", {
class: "image-nav-arrow image-nav-arrow-prev",
type: "button",
title: t("viewer.image_prev"),
"aria-label": t("viewer.image_prev"),
hidden: true,
}, [icon("chevron-left", 32)]);
const nextArrow = el("button", {
class: "image-nav-arrow image-nav-arrow-next",
type: "button",
title: t("viewer.image_next"),
"aria-label": t("viewer.image_next"),
hidden: true,
}, [icon("chevron-right", 32)]);
stage.appendChild(prevArrow);
stage.appendChild(nextArrow);
body.appendChild(stage);
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
@@ -641,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;
@@ -661,12 +738,12 @@ export function renderImageViewer(area, data) {
const renderMeta = () => {
const rows = [
[t("viewer.image_type"), data.image_mime || ""],
[t("viewer.image_type"), currentMeta.image_mime || ""],
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
[t("viewer.metadata_size"), formatBytes(data.size_bytes)],
[t("viewer.metadata_path"), path],
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
[t("viewer.metadata_path"), currentPath],
];
if (data.modified) rows.push([t("viewer.metadata_modified"), data.modified]);
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
metaPanel.innerHTML = "";
const dl = el("dl", {});
rows.forEach(([k, v]) => {
@@ -676,46 +753,120 @@ export function renderImageViewer(area, data) {
metaPanel.appendChild(dl);
};
// ── Sibling navigation ─────────────────────────────────────────────────
// ── Sibling navigation (in place, #111) ────────────────────────────────
let siblings = [];
let currentIndex = -1;
let thumbs = [];
const preloaded = new Set();
const preload = (i) => {
if (!siblings.length) return;
const it = siblings[(i % siblings.length + siblings.length) % siblings.length];
if (!it || preloaded.has(it.path)) return;
preloaded.add(it.path);
const pre = new Image();
pre.src = buildImageUrl(vault, it.path);
};
const updateStripActive = () => {
thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex));
const active = thumbs[currentIndex];
if (!active) return;
// Scroll the filmstrip only (never the document) to reveal the active thumb.
const left = active.offsetLeft;
const right = left + active.offsetWidth;
if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8);
else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8;
};
const updateArrows = () => {
const multi = siblings.length > 1;
prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi;
prevArrow.disabled = nextArrow.disabled = !multi;
counter.hidden = !multi;
counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : "";
};
/** Swap to the sibling at *index* without rebuilding the viewer. */
const showSibling = (index) => {
const item = siblings[index];
if (!item || item.path === currentPath) return;
currentIndex = index;
currentPath = item.path;
currentTitle = item.name;
imgUrl = buildImageUrl(vault, currentPath);
currentMeta = {
image_mime: imageMimeFor(currentPath),
size_bytes: item.size,
modified: null,
};
scale = 1; tx = 0; ty = 0;
applyTransform();
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
img.alt = currentTitle;
img.src = imgUrl;
titleEl.textContent = currentTitle;
titleEl.title = currentPath;
updateStripActive();
updateArrows();
state.currentVault = vault;
state.currentPath = currentPath;
try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ }
preload(index - 1);
preload(index + 1);
if (!metaPanel.hidden) renderMeta();
};
const go = (delta) => {
if (siblings.length < 2 || currentIndex < 0) return;
const next = (currentIndex + delta + siblings.length) % siblings.length;
_imageViewerNavPending = true;
openFile(vault, siblings[next].path);
showSibling((currentIndex + delta + siblings.length) % siblings.length);
};
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 = "";
siblings.forEach((s, i) => {
thumbs = siblings.map((s, i) => {
const thumb = el("img", {
class: `image-thumb${i === currentIndex ? " active" : ""}`,
src: buildThumbUrl(vault, s.path, 96),
alt: s.name,
title: s.name,
loading: "lazy",
decoding: "async",
});
thumb.addEventListener("click", () => {
if (s.path === path) return;
_imageViewerNavPending = true;
openFile(vault, s.path);
});
thumb.addEventListener("click", () => showSibling(i));
strip.appendChild(thumb);
return thumb;
});
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 = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
siblings = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
currentIndex = siblings.findIndex((it) => it.path === path);
prevBtn.disabled = nextBtn.disabled = siblings.length < 2;
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
siblings = await listImageSiblings(vault, dir);
currentIndex = siblings.findIndex((it) => it.path === currentPath);
renderStrip();
updateArrows();
preload(currentIndex - 1);
preload(currentIndex + 1);
} catch (_) { /* navigation is best-effort */ }
})();
@@ -726,13 +877,15 @@ export function renderImageViewer(area, data) {
setZoom(scale * factor);
}, { passive: false });
const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow"));
let dragging = false;
let startX = 0;
let startY = 0;
let startTx = 0;
let startTy = 0;
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (e.button !== 0 || isArrow(e)) return;
dragging = true;
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
stage.classList.add("panning");
@@ -753,7 +906,15 @@ export function renderImageViewer(area, data) {
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
stage.addEventListener("dblclick", resetView);
stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); });
const stopArrowPointer = (e) => e.stopPropagation();
prevArrow.addEventListener("pointerdown", stopArrowPointer);
nextArrow.addEventListener("pointerdown", stopArrowPointer);
prevArrow.addEventListener("dblclick", stopArrowPointer);
nextArrow.addEventListener("dblclick", stopArrowPointer);
prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); });
nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); });
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
@@ -762,7 +923,7 @@ export function renderImageViewer(area, data) {
nextBtn.addEventListener("click", () => go(1));
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
downloadBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
window.open(dlUrl, "_blank");
});
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
@@ -782,10 +943,17 @@ export function renderImageViewer(area, data) {
syncLightbox();
});
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
img.addEventListener("load", () => {
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
if (!metaPanel.hidden) renderMeta();
});
img.addEventListener("error", () => {
stage.innerHTML = "";
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
img.style.display = "none";
if (!stage.querySelector(".image-error")) {
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
}
});
const onKey = (e) => {
@@ -836,13 +1004,13 @@ export function renderFile(data) {
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// BUG-072 — carry the lightbox / metadata-panel state over an image→image
// navigation; any other render starts from a clean viewer.
if (!data.is_image || !_imageViewerNavPending) {
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
// viewer. Image→image navigation now happens in place (see renderImageViewer),
// so the viewer is never rebuilt between siblings.
if (!data.is_image) {
_imageViewerState.lightbox = false;
_imageViewerState.meta = false;
}
_imageViewerNavPending = false;
const area = getContentArea();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
+9 -2
View File
@@ -777,6 +777,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 +1107,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 +1310,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",
@@ -1682,6 +1685,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 +1835,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",
@@ -2146,7 +2153,7 @@
"guide105.lib_h3_conflicts": "Sync conflicts",
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
"guide105.lib_h3_attach": "Attachments & media",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, smooth navigation between images in the folder — image resized to the frame, hover side arrows, persistent thumbnail filmstrip —, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
+9 -2
View File
@@ -777,6 +777,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 +1107,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 +1310,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",
@@ -1682,6 +1685,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 +1835,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",
@@ -2146,7 +2153,7 @@
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
"guide105.lib_h3_attach": "Fichiers joints & médias",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation fluide entre les images du dossier — image redimensionnée au cadre, flèches latérales au survol, pellicule de miniatures persistante —, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
+227 -5
View File
@@ -3815,8 +3815,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 {
@@ -6039,6 +6039,100 @@ 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));
}
.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
--------------------------------------------------------------------------- */
@@ -9640,10 +9734,17 @@ body.desktop-mode .editor-container {
}
/* #108-D — dedicated image viewer: zoom, pan, filmstrip, metadata, lightbox */
/* #111 — the viewer owns the whole content box (no page scrolling): the image
is always resized to the available frame and the toolbar/filmstrip stay put. */
.content-area:has(> .image-viewer-container) {
padding: 0;
overflow: hidden;
}
.image-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
outline: none;
}
.image-toolbar {
@@ -9673,10 +9774,25 @@ body.desktop-mode .editor-container {
min-width: 46px;
text-align: center;
}
.image-counter {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0 6px;
white-space: nowrap;
}
.image-counter[hidden] {
display: none;
}
.image-viewer-body {
flex: 1;
display: flex;
/* #111 — stretch (not center) so .image-stage is capped to the body height and
the image can actually resolve max-height:100% against a definite frame. */
align-items: stretch;
padding: 0;
min-height: 0;
overflow: hidden;
}
.image-stage {
flex: 1;
@@ -9686,6 +9802,9 @@ body.desktop-mode .editor-container {
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
padding: 16px;
box-sizing: border-box;
background: var(--surface2);
cursor: grab;
touch-action: none;
@@ -9694,6 +9813,8 @@ body.desktop-mode .editor-container {
cursor: grabbing;
}
.image-main {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
@@ -9706,18 +9827,118 @@ body.desktop-mode .editor-container {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* #111 — translucent overlay arrows along the frame edges (revealed on hover) */
.image-nav-arrow {
position: absolute;
top: 0;
bottom: 0;
width: clamp(44px, 6vw, 84px);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: #fff;
opacity: 0;
cursor: pointer;
z-index: 6;
transition: opacity 160ms ease, background 160ms ease;
}
.image-nav-arrow[hidden] {
display: none;
}
.image-nav-arrow-prev {
left: 0;
}
.image-nav-arrow-next {
right: 0;
}
.image-nav-arrow-prev:hover,
.image-nav-arrow-prev:focus-visible {
background: linear-gradient(to right, rgba(0, 0, 0, 0.42), transparent);
}
.image-nav-arrow-next:hover,
.image-nav-arrow-next:focus-visible {
background: linear-gradient(to left, rgba(0, 0, 0, 0.42), transparent);
}
.image-stage:hover .image-nav-arrow {
opacity: 0.4;
}
.image-stage .image-nav-arrow:hover,
.image-stage .image-nav-arrow:focus-visible {
opacity: 1;
outline: none;
}
.image-nav-arrow .icon {
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85));
}
@media (hover: none) {
.image-nav-arrow {
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;
@@ -9769,6 +9990,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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.19.2",
"version": "2.21.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": {
+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();
});
});
+63
View File
@@ -123,4 +123,67 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
await expect(metaPanel).toBeVisible();
await expect(title).not.toHaveText(titleBefore);
});
test('navigation en place : flèches latérales, pellicule persistante et cadre ajusté (#111)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const container = page.locator('#content-area .image-viewer-container');
const strip = page.locator('#content-area .image-nav-strip');
const main = page.locator('#content-area img.image-main');
await expect(strip).toBeVisible({ timeout: 10000 });
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
// Marqueur : la navigation en place ne doit PAS recréer le conteneur.
await container.evaluate((el) => { el.dataset.inplace = '1'; });
// L'image reste contenue dans le cadre (jamais plus grande).
const stageBox = await page.locator('#content-area .image-stage').boundingBox();
const imgBox = await main.boundingBox();
expect(imgBox.width).toBeLessThanOrEqual(stageBox.width + 1);
expect(imgBox.height).toBeLessThanOrEqual(stageBox.height + 1);
// Flèche superposée révélée au survol.
const nextArrow = page.locator('#content-area .image-nav-arrow-next');
await expect(nextArrow).toBeAttached();
await nextArrow.hover();
await expect.poll(() => nextArrow.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
const title = page.locator('#content-area .image-title');
const before = await title.innerText();
await nextArrow.click();
await expect(title).not.toHaveText(before);
await expect(strip).toBeVisible();
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
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);
});
});
+61 -10
View File
@@ -77,36 +77,66 @@ test("renderImageViewer never uses the JSON raw endpoint as an image src", () =>
assert.ok(start !== -1 && stop > start, "renderImageViewer block not found");
const block = viewer.slice(start, stop);
assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw");
assert.match(block, /buildImageUrl\(vault, path\)/);
assert.match(block, /buildImageUrl\(vault, currentPath\)/);
});
test("image viewer supports wheel zoom, drag pan and double-click reset", () => {
assert.match(viewer, /stage\.addEventListener\("wheel"/);
assert.match(viewer, /stage\.addEventListener\("pointerdown"/);
assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/);
assert.match(viewer, /stage\.addEventListener\("dblclick", \(e\) => \{ if \(!isArrow\(e\)\) resetView\(\); \}\)/);
assert.match(viewer, /renderImageViewer/);
});
test("image viewer exposes thumbnail navigation via /api/media thumb", () => {
assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/);
assert.match(viewer, /let siblings = \[\]/);
assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/);
// #111 — the sibling list comes from the memoised directory listing.
assert.match(viewer, /listImageSiblings/);
});
// BUG-072 — lightbox + metadata panel must survive ←/→ (and filmstrip) navigation.
// BUG-072 — lightbox + metadata panel keep their state; #111 — navigation is
// in place, so the viewer (and its filmstrip) is no longer rebuilt.
test("image viewer persists lightbox + metadata state across navigation", () => {
assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/);
assert.match(viewer, /let _imageViewerNavPending = false/);
// Both navigation paths flag the next render as an image→image navigation.
assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, siblings\[next\]\.path\)/);
assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, s\.path\)/);
// renderFile resets the state only when the next render is not a carried-over image.
assert.match(viewer, /if \(!data\.is_image \|\| !_imageViewerNavPending\)/);
// renderFile only resets the viewer state when the next render is not an image.
assert.match(viewer, /if \(!data\.is_image\) \{\s*\n\s*_imageViewerState\.lightbox = false;/);
// The persisted state drives the container class and the panel visibility.
assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/);
assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/);
});
// #111 — switching siblings swaps the <img> in place instead of re-rendering
// (no openFile round-trip, filmstrip stays in the DOM, neighbours preloaded).
test("image viewer navigates between siblings in place (#111)", () => {
assert.doesNotMatch(viewer, /_imageViewerNavPending/);
assert.match(viewer, /const showSibling = \(index\) =>/);
assert.match(viewer, /const go = \(delta\) => \{\s*\n\s*if \(siblings\.length < 2[^)]*\) return;\s*\n\s*showSibling\(/);
// Thumbnail clicks and the overlay arrows both call showSibling/go in place.
assert.match(viewer, /thumb\.addEventListener\("click", \(\) => showSibling\(i\)\)/);
assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-prev"/);
assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-next"/);
// The active thumbnail is only re-highlighted / scrolled, never rebuilt.
assert.match(viewer, /updateStripActive/);
assert.match(viewer, /const preload = \(i\) =>/);
// Directory listing is cached for a short TTL.
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*\{/);
@@ -115,6 +145,27 @@ test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-viewer-container\.lightbox\s*\{/);
});
// #111 — the viewer fills the content box and the image is capped to the frame.
test("style.css resizes the image to the available frame (#111)", () => {
assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*padding:\s*0/);
assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*overflow:\s*hidden/);
const main = /\.image-main\s*\{([^}]*)\}/.exec(css);
assert.ok(main, ".image-main rule not found");
assert.match(main[1], /max-width:\s*100%/);
assert.match(main[1], /max-height:\s*100%/);
});
// #111 — translucent overlay arrows on both frame edges, revealed on hover.
test("style.css styles the overlay navigation arrows (#111)", () => {
assert.match(css, /\.image-nav-arrow\s*\{/);
assert.match(css, /\.image-nav-arrow-prev\s*\{\s*left:\s*0/);
assert.match(css, /\.image-nav-arrow-next\s*\{\s*right:\s*0/);
const arrow = /\.image-nav-arrow\s*\{([^}]*)\}/.exec(css);
assert.ok(arrow, ".image-nav-arrow rule not found");
assert.match(arrow[1], /opacity:\s*0/);
assert.match(css, /\.image-nav-arrow:hover[\s\S]*?opacity:\s*1/);
});
// BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip,
// and it stays visible in lightbox mode.
test("style.css lays the metadata panel out as a right sidebar", () => {
+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) {