feat: ordre naturel des sections Configurations et avatar utilisateur #113
This commit is contained in:
+25
-1
@@ -125,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;
|
||||
@@ -354,7 +366,19 @@ const AuthManager = {
|
||||
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);
|
||||
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();
|
||||
|
||||
|
||||
@@ -2356,6 +2356,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 +2494,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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user