feat: barre d'outils de lecture epinglee, coloration syntaxique des fichiers de code et avatars predefinis (#115, #117, BUG-078)
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -1660,6 +1660,27 @@
|
||||
<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>
|
||||
<span class="profile-avatar-label" data-i18n="config.avatar_presets_label">Avatars prédéfinis</span>
|
||||
<div
|
||||
class="profile-avatar-presets"
|
||||
id="profile-avatar-presets"
|
||||
role="radiogroup"
|
||||
data-i18n-attr="aria-label:config.avatar_presets_label"
|
||||
aria-label="Avatars prédéfinis"
|
||||
>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="Chat.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/Chat.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="chien.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/chien.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="elephan.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/elephan.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="hibou.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/hibou.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="koala.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/koala.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="lapin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lapin.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="lion.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lion.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="ours.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/ours.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="penda.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/penda.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="pingouin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/pingouin.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="raton.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/raton.jpg" alt="" loading="lazy" /></button>
|
||||
<button type="button" class="profile-avatar-preset" data-avatar="tigre.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/tigre.jpg" alt="" loading="lazy" /></button>
|
||||
</div>
|
||||
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
|
||||
@@ -2534,6 +2534,7 @@ function initProfile() {
|
||||
var overlayBtn = document.getElementById('profile-avatar-overlay');
|
||||
var previewBox = document.getElementById('profile-avatar-preview');
|
||||
var removeBtn = document.getElementById('profile-avatar-remove');
|
||||
var presetsBox = document.getElementById('profile-avatar-presets');
|
||||
if (!avatarField || !avatarInput || !chooseBtn) return;
|
||||
|
||||
// The avatar only exists on a real account — hide it when auth is off.
|
||||
@@ -2554,6 +2555,59 @@ function initProfile() {
|
||||
})
|
||||
.catch(function () { /* non-bloquant */ });
|
||||
|
||||
// ── Preset avatars (#117) ────────────────────────────────────────
|
||||
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
|
||||
function markActivePreset(name) {
|
||||
if (!presetsBox) return;
|
||||
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
|
||||
var on = !!name && btn.getAttribute('data-avatar') === name;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-checked', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
function clearActivePreset() {
|
||||
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
markActivePreset(null);
|
||||
}
|
||||
function rememberActivePreset(name) {
|
||||
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
|
||||
markActivePreset(name);
|
||||
}
|
||||
var savedPreset = null;
|
||||
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
markActivePreset(savedPreset);
|
||||
|
||||
if (presetsBox) {
|
||||
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
var preset = btn.getAttribute('data-avatar');
|
||||
if (!preset) return;
|
||||
_profileAvatarError(null);
|
||||
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
|
||||
all.forEach(function (b) { b.disabled = true; });
|
||||
try {
|
||||
// Load the bundled image, then reuse the upload pipeline (center-crop
|
||||
// + 256 px JPEG) so it stores exactly like an imported picture.
|
||||
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
var blob = await res.blob();
|
||||
var dataUrl = await _resizeAvatarFile(blob);
|
||||
var user = await _patchProfileAvatar(dataUrl);
|
||||
AuthManager.updateCachedUser(user);
|
||||
_renderProfileAvatar(user.avatar || dataUrl);
|
||||
AuthManager.renderUserSection();
|
||||
rememberActivePreset(preset);
|
||||
showToast(t('config.avatar_updated'), 'success');
|
||||
} catch (err) {
|
||||
_profileAvatarError('config.avatar_upload_failed');
|
||||
console.error('Avatar preset failed:', err);
|
||||
} finally {
|
||||
all.forEach(function (b) { b.disabled = false; });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
_profileAvatarError(null);
|
||||
avatarInput.click();
|
||||
@@ -2587,6 +2641,7 @@ function initProfile() {
|
||||
AuthManager.updateCachedUser(user);
|
||||
_renderProfileAvatar(user.avatar || dataUrl);
|
||||
AuthManager.renderUserSection();
|
||||
clearActivePreset(); // an imported photo is not a preset
|
||||
showToast(t('config.avatar_updated'), 'success');
|
||||
} catch (err) {
|
||||
_profileAvatarError('config.avatar_upload_failed');
|
||||
@@ -2605,6 +2660,7 @@ function initProfile() {
|
||||
AuthManager.updateCachedUser(user);
|
||||
_renderProfileAvatar(null);
|
||||
AuthManager.renderUserSection();
|
||||
clearActivePreset();
|
||||
showToast(t('config.avatar_removed'), 'success');
|
||||
} catch (err) {
|
||||
_profileAvatarError('config.avatar_upload_failed');
|
||||
|
||||
@@ -598,6 +598,17 @@ function applyTheme(themeKey, mode) {
|
||||
// Notify other components of theme change
|
||||
try {
|
||||
root.setAttribute('data-theme', mode);
|
||||
// Syntax highlighting follows the light/dark mode (BUG-078): the theme
|
||||
// *key* is not a mode, so toggling the sheets by key used to disable both
|
||||
// and strip every code block of its colours. Sepia/high-contrast reuse the
|
||||
// light palette.
|
||||
var darkSheet = document.getElementById('hljs-theme-dark');
|
||||
var lightSheet = document.getElementById('hljs-theme-light');
|
||||
if (darkSheet && lightSheet) {
|
||||
var isDark = mode === 'dark';
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
@@ -137,14 +137,26 @@ export const RightSidebarManager = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme
|
||||
// ---------------------------------------------------------------------------
|
||||
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem("obsigate-theme") || "dark";
|
||||
applyTheme(saved);
|
||||
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
|
||||
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
|
||||
// the persisted mode here so the first paint and the highlight.js stylesheet
|
||||
// are right before the async theme init runs (BUG-078).
|
||||
let mode = "dark";
|
||||
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
|
||||
applyTheme(mode);
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("obsigate-theme", theme);
|
||||
let mode = theme;
|
||||
if (_THEME_MODES.indexOf(mode) === -1) {
|
||||
// Callers may pass a theme key (legacy): fall back to the persisted mode.
|
||||
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
|
||||
}
|
||||
const isDark = mode === "dark";
|
||||
document.documentElement.setAttribute("data-theme", mode);
|
||||
|
||||
// Update theme button icon and label
|
||||
const themeBtn = document.getElementById("theme-toggle");
|
||||
@@ -152,23 +164,23 @@ export function applyTheme(theme) {
|
||||
if (themeBtn && themeLabel) {
|
||||
const icon = themeBtn.querySelector("i");
|
||||
if (icon) {
|
||||
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
|
||||
icon.setAttribute("data-lucide", isDark ? "moon" : "sun");
|
||||
}
|
||||
themeLabel.textContent = theme === "dark" ? t('theme.dark') : t('theme.light');
|
||||
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// Swap highlight.js theme
|
||||
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
|
||||
const darkSheet = document.getElementById("hljs-theme-dark");
|
||||
const lightSheet = document.getElementById("hljs-theme-light");
|
||||
if (darkSheet && lightSheet) {
|
||||
darkSheet.disabled = theme !== "dark";
|
||||
lightSheet.disabled = theme !== "light";
|
||||
darkSheet.disabled = !isDark;
|
||||
lightSheet.disabled = isDark;
|
||||
}
|
||||
|
||||
// Update Mermaid theme for newly rendered diagrams
|
||||
import('./mermaid-viewer.js').then(function(m) {
|
||||
m.updateMermaidTheme(theme === 'dark');
|
||||
m.updateMermaidTheme(isDark);
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1418,7 +1418,12 @@ export function renderFile(data) {
|
||||
// Assemble
|
||||
area.innerHTML = "";
|
||||
area.appendChild(breadcrumb);
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, fileActions)]));
|
||||
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
|
||||
// #115 — the action bar is a direct child of the scroll container (wrapped in
|
||||
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
|
||||
// inside `.file-header` it would stop sticking as soon as the header left the
|
||||
// viewport.
|
||||
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
|
||||
if (fmSection) area.appendChild(fmSection);
|
||||
area.appendChild(mdDiv);
|
||||
area.appendChild(rawDiv);
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
"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_presets_label": "Or pick a preset avatar",
|
||||
"config.avatar_choose": "Choose an image",
|
||||
"config.avatar_remove": "Remove picture",
|
||||
"config.avatar_updated": "Profile picture updated",
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
"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_presets_label": "Ou choisissez un avatar prédéfini",
|
||||
"config.avatar_choose": "Choisir une image",
|
||||
"config.avatar_remove": "Supprimer la photo",
|
||||
"config.avatar_updated": "Photo de profil mise à jour",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=3">
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
|
||||
@@ -871,8 +871,13 @@ const RightSidebarManager = {
|
||||
group.forEach(function (b) { actionsDiv.appendChild(b); });
|
||||
});
|
||||
|
||||
header.appendChild(actionsDiv);
|
||||
area.appendChild(header);
|
||||
// #115 — pinned action bar (direct child of the scroll container) so it
|
||||
// stays visible while long documents scroll, as in the main viewer.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "file-toolbar";
|
||||
toolbar.appendChild(actionsDiv);
|
||||
area.appendChild(toolbar);
|
||||
|
||||
// Frontmatter — Accent Card
|
||||
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||||
|
||||
@@ -1767,7 +1767,22 @@ select {
|
||||
|
||||
/* File header */
|
||||
.file-header {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* #115 — sticky document action bar: stays visible while long content scrolls.
|
||||
A direct child of `.content-area` (the scroll container), opaque so the text
|
||||
scrolling underneath never bleeds through. */
|
||||
.file-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
margin: 0 0 16px;
|
||||
padding: 8px 0;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.file-toolbar .file-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
.file-title {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
@@ -2670,6 +2685,32 @@ select {
|
||||
.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; }
|
||||
/* #117 — preset avatar gallery */
|
||||
.profile-avatar-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
max-width: 420px;
|
||||
}
|
||||
.profile-avatar-preset {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
aspect-ratio: 1;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
.profile-avatar-preset img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.profile-avatar-preset:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.profile-avatar-preset.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.profile-avatar-preset:disabled { opacity: 0.6; cursor: progress; }
|
||||
.config-save-btn {
|
||||
padding: 6px 18px; font-size: 0.75rem; font-weight: 600;
|
||||
background: var(--accent); color: #fff;
|
||||
@@ -4920,6 +4961,11 @@ body.resizing-v {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
/* Preset avatars: 4 per row on narrow screens (44px touch targets). */
|
||||
#config-modal .profile-avatar-presets {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
max-width: 100%;
|
||||
}
|
||||
#config-modal .profile-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -11853,6 +11899,7 @@ body.reading-mode .main-body {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
body.reading-mode .breadcrumb,
|
||||
body.reading-mode .file-toolbar,
|
||||
body.reading-mode .file-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v25';
|
||||
const SW_VERSION = 'v26';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||