feat(user-avatar): users can now edit there image (image is taken from OIDC picture)
This commit is contained in:
@@ -7,6 +7,7 @@ import { loadFiles } from './filesView.js';
|
||||
import { updateStorageUsageDisplay } from './main.js';
|
||||
import { app } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
import { updateUserMenuData } from './userMenu.js';
|
||||
|
||||
/**
|
||||
* @import {User} from '../core/types.js'
|
||||
@@ -96,14 +97,7 @@ async function checkAuthentication() {
|
||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||
if (userData.username) {
|
||||
// We have cached user data — render immediately, refresh in background
|
||||
const userInitials = userData.username.substring(0, 2).toUpperCase();
|
||||
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach((el) => {
|
||||
el.textContent = userInitials;
|
||||
});
|
||||
const menuName = document.getElementById('user-menu-name');
|
||||
const menuEmail = document.getElementById('user-menu-email');
|
||||
if (menuName) menuName.textContent = userData.username;
|
||||
if (menuEmail) menuEmail.textContent = userData.email || '';
|
||||
updateUserMenuData();
|
||||
|
||||
updateStorageUsageDisplay(userData);
|
||||
|
||||
@@ -145,10 +139,7 @@ async function checkAuthentication() {
|
||||
try {
|
||||
const freshData = await refreshUserData();
|
||||
if (freshData?.username) {
|
||||
const userInitials = freshData.username.substring(0, 2).toUpperCase();
|
||||
document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach((el) => {
|
||||
el.textContent = userInitials;
|
||||
});
|
||||
updateUserMenuData();
|
||||
updateStorageUsageDisplay(freshData);
|
||||
resolveHomeFolder().then(() => loadFiles());
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -481,7 +481,7 @@ const ui = {
|
||||
const id = cell.dataset.ownerId;
|
||||
cell.dataset.ownerResolved = '1';
|
||||
if (!id) continue;
|
||||
cell.replaceChildren(createUserVignette(id, 'sm'));
|
||||
cell.replaceChildren(createUserVignette(id, 'list'));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* User menu, profile modal and logout logic
|
||||
*/
|
||||
|
||||
import { createUserVignette } from '../components/userVignette.js';
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
@@ -21,6 +22,9 @@ function setupUserMenu() {
|
||||
|
||||
if (!wrapper || !avatarBtn || !menu) return;
|
||||
|
||||
// Populate avatar and name immediately from localStorage on every page load.
|
||||
updateUserMenuData();
|
||||
|
||||
avatarBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = wrapper.classList.contains('open');
|
||||
@@ -156,20 +160,41 @@ function setupUserMenu() {
|
||||
fetchAppVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount avatar-only vignettes for the toolbar button and the dropdown header.
|
||||
* Called whenever user data in localStorage changes (login, photo save, etc.).
|
||||
*
|
||||
* The toolbar button (#user-avatar-btn) and the menu header (.user-menu-header)
|
||||
* are the stable mount points. Both receive a fresh vignette each call so
|
||||
* the photo / initials are always in sync with the current localStorage state.
|
||||
*
|
||||
* @param {string} userId
|
||||
*/
|
||||
function _mountAvatarVignettes(userId) {
|
||||
const avatarBtn = document.getElementById('user-avatar-btn');
|
||||
if (avatarBtn) {
|
||||
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
|
||||
}
|
||||
|
||||
const menuHeader = document.querySelector('.user-menu-header');
|
||||
if (menuHeader) {
|
||||
menuHeader.replaceChildren(createUserVignette(userId, 'xl', { showName: true, showEmail: true }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateUserMenuData() {
|
||||
const USER_DATA_KEY = 'oxicloud_user';
|
||||
/** @type {import('../core/types.js').User} */
|
||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||
|
||||
const nameEl = document.getElementById('user-menu-name');
|
||||
const emailEl = document.getElementById('user-menu-email');
|
||||
const avatarEl = document.getElementById('user-menu-avatar');
|
||||
const storageFill = document.getElementById('user-menu-storage-fill');
|
||||
const storageText = document.getElementById('user-menu-storage-text');
|
||||
|
||||
if (userData.username) {
|
||||
if (nameEl) nameEl.textContent = userData.username;
|
||||
if (emailEl) emailEl.textContent = userData.email || '';
|
||||
if (avatarEl) avatarEl.textContent = userData.username.substring(0, 2).toUpperCase();
|
||||
if (userData.username && userData.id) {
|
||||
_mountAvatarVignettes(userData.id);
|
||||
}
|
||||
|
||||
const usedBytes = userData.storage_used_bytes || 0;
|
||||
@@ -287,4 +312,4 @@ async function logout() {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
export { logout, setupUserMenu, showUserProfileModal };
|
||||
export { logout, setupUserMenu, showUserProfileModal, updateUserMenuData };
|
||||
|
||||
@@ -21,7 +21,7 @@ import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
import { Modal } from './modal.js';
|
||||
import { _colorIndex, _initials } from './userVignette.js';
|
||||
import { createUserVignette } from './userVignette.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
|
||||
|
||||
@@ -337,27 +337,7 @@ const shareModal = {
|
||||
item.className = 'smd-suggestion-item';
|
||||
item.tabIndex = 0;
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`;
|
||||
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
|
||||
avatar.textContent = _initials(displayName);
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'smd-suggestion-name';
|
||||
nameEl.textContent = displayName;
|
||||
|
||||
const primaryEmail = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
|
||||
if (primaryEmail) {
|
||||
const emailEl = document.createElement('span');
|
||||
emailEl.className = 'smd-suggestion-email';
|
||||
emailEl.textContent = primaryEmail;
|
||||
item.appendChild(avatar);
|
||||
item.appendChild(nameEl);
|
||||
item.appendChild(emailEl);
|
||||
} else {
|
||||
item.appendChild(avatar);
|
||||
item.appendChild(nameEl);
|
||||
}
|
||||
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
||||
|
||||
const select = () => onSelect(c);
|
||||
item.addEventListener('click', select);
|
||||
@@ -415,13 +395,7 @@ const shareModal = {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'smd-chip';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`;
|
||||
const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8);
|
||||
avatar.textContent = _initials(displayName);
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.textContent = displayName;
|
||||
const vignette = createUserVignette(c.id, 'xs');
|
||||
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'smd-chip-remove';
|
||||
@@ -434,8 +408,7 @@ const shareModal = {
|
||||
if (addBtn) addBtn.disabled = this._stagedUsers.length === 0;
|
||||
});
|
||||
|
||||
chip.appendChild(avatar);
|
||||
chip.appendChild(nameEl);
|
||||
chip.appendChild(vignette);
|
||||
chip.appendChild(rm);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
@@ -526,18 +499,7 @@ const shareModal = {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'smd-member-row';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`;
|
||||
|
||||
// Resolve display name async
|
||||
systemUsers.getDisplayName(entry.grant.subject.id).then((name) => {
|
||||
avatar.textContent = _initials(name);
|
||||
nameEl.textContent = name;
|
||||
});
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'smd-member-name';
|
||||
nameEl.textContent = `${entry.grant.subject.id.slice(0, 8)}…`;
|
||||
const vignette = createUserVignette(entry.grant.subject.id, 'md');
|
||||
|
||||
const roleSelect = document.createElement('select');
|
||||
roleSelect.className = 'smd-member-role-select';
|
||||
@@ -568,8 +530,7 @@ const shareModal = {
|
||||
this._refreshMemberGroups();
|
||||
});
|
||||
|
||||
row.appendChild(avatar);
|
||||
row.appendChild(nameEl);
|
||||
row.appendChild(vignette);
|
||||
row.appendChild(roleSelect);
|
||||
row.appendChild(removeBtn);
|
||||
return row;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* UserVignette — reusable user avatar + name inline component.
|
||||
* UserVignette — reusable user avatar component, two display modes.
|
||||
*
|
||||
* Renders a coloured circle with initials (or photo when available) alongside
|
||||
* an asynchronously-resolved display name. Used in:
|
||||
* • Owner column (list view) via `ui.resolveOwnerCells()`
|
||||
* • ShareModal member rows / chips / suggestion items
|
||||
* Mode 1 — avatar + name (default):
|
||||
* A coloured circle with initials (or photo) alongside an async-resolved
|
||||
* display name. Used in the owner column, ShareModal rows / chips / items.
|
||||
*
|
||||
* Mode 2 — avatar only ({ showName: false }):
|
||||
* The circle alone, no name span. Used in the user-menu toolbar button
|
||||
* and the dropdown header where the name is rendered separately.
|
||||
*
|
||||
* Usage:
|
||||
* import { createUserVignette } from './userVignette.js';
|
||||
* // with name
|
||||
* cell.replaceChildren(createUserVignette(userId, 'sm'));
|
||||
* // avatar only
|
||||
* btn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
|
||||
*/
|
||||
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
@@ -42,22 +48,60 @@ export function _colorIndex(userId) {
|
||||
return Math.abs(hash) % 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a photo inside an avatar element, falling back to initials on error.
|
||||
* @param {HTMLElement} avatar The `.user-vignette__avatar` element.
|
||||
* @param {string} photoUrl Non-empty photo URL or data URI.
|
||||
* @param {string} name Display name for the alt attribute / fallback.
|
||||
*/
|
||||
function _applyPhoto(avatar, photoUrl, name) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = name;
|
||||
img.src = photoUrl;
|
||||
img.onerror = () => {
|
||||
// Photo failed to load — fall back to initials
|
||||
avatar.replaceChildren();
|
||||
avatar.textContent = _initials(name);
|
||||
};
|
||||
avatar.replaceChildren(img);
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize
|
||||
* Available sizes. Each maps to a `.user-vignette--{size}` CSS modifier:
|
||||
* xs → 20 px (chip avatar, small inline contexts)
|
||||
* sm → 24 px (default; ShareModal suggestions, compact rows)
|
||||
* list → 36 px (owner column in list view)
|
||||
* md → 32 px (ShareModal member rows)
|
||||
* lg → 40 px (profile page, larger lists)
|
||||
* menu → 38 px (user-menu toolbar button)
|
||||
* xl → 48 px (user-menu dropdown header)
|
||||
*
|
||||
* @typedef {'xs'|'sm'|'list'|'md'|'lg'|'menu'|'xl'} VignetteSize
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a user vignette element: a coloured initials circle + async-resolved
|
||||
* display name span. The element is returned immediately with a short-UUID
|
||||
* placeholder; the name resolves in the background via `systemUsers`.
|
||||
* @typedef {Object} VignetteOptions
|
||||
* @property {boolean} [showName=true]
|
||||
* When false, only the avatar circle is rendered — no name span.
|
||||
* Use this when the name is displayed separately (e.g. the user-menu header).
|
||||
* @property {boolean} [showEmail=false]
|
||||
* When true (and showName is true), the primary email address is shown below
|
||||
* the name in a lighter style. Name and email are wrapped in a
|
||||
* `.user-vignette__info` column. Has no effect when showName is false.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a user vignette element. Returns immediately with a placeholder;
|
||||
* the display name, email, and photo resolve asynchronously via `systemUsers`.
|
||||
*
|
||||
* @param {string} userId UUID of the user
|
||||
* @param {VignetteSize} [size='sm']
|
||||
* @param {string} userId UUID of the user
|
||||
* @param {VignetteSize} [size='sm']
|
||||
* @param {VignetteOptions} [options]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createUserVignette(userId, size = 'sm') {
|
||||
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false } = {}) {
|
||||
const colorIdx = _colorIndex(userId);
|
||||
|
||||
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
|
||||
@@ -67,19 +111,43 @@ export function createUserVignette(userId, size = 'sm') {
|
||||
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
|
||||
// Temporary placeholder: first two chars of UUID
|
||||
avatar.textContent = userId.slice(0, 2).toUpperCase();
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'user-vignette__name';
|
||||
nameEl.textContent = `${userId.slice(0, 8)}…`;
|
||||
|
||||
wrapper.appendChild(avatar);
|
||||
wrapper.appendChild(nameEl);
|
||||
|
||||
// Resolve full name asynchronously and update both avatar initials and name
|
||||
systemUsers.getDisplayName(userId).then((name) => {
|
||||
avatar.textContent = _initials(name);
|
||||
nameEl.textContent = name;
|
||||
});
|
||||
/** @type {HTMLElement | null} */
|
||||
const nameEl = showName ? document.createElement('span') : null;
|
||||
|
||||
/** @type {HTMLElement | null} */
|
||||
const emailEl = showName && showEmail ? document.createElement('span') : null;
|
||||
|
||||
if (nameEl) {
|
||||
nameEl.className = 'user-vignette__name';
|
||||
nameEl.textContent = `${userId.slice(0, 8)}…`;
|
||||
|
||||
if (emailEl) {
|
||||
// Wrap name + email in a column so they stack vertically.
|
||||
emailEl.className = 'user-vignette__email';
|
||||
const info = document.createElement('span');
|
||||
info.className = 'user-vignette__info';
|
||||
info.appendChild(nameEl);
|
||||
info.appendChild(emailEl);
|
||||
wrapper.appendChild(info);
|
||||
} else {
|
||||
wrapper.appendChild(nameEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve name, photo, and (when requested) email asynchronously.
|
||||
Promise.all([systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null)]).then(
|
||||
([name, photo, email]) => {
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
if (emailEl) emailEl.textContent = email ?? '';
|
||||
if (photo) {
|
||||
_applyPhoto(avatar, photo, name);
|
||||
} else {
|
||||
avatar.textContent = _initials(name);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -88,11 +88,25 @@ function installFetchInterceptor() {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Auth endpoints must bypass retry: a 401 on /api/auth/* means the
|
||||
// credentials themselves are invalid; retrying would cause a loop.
|
||||
// True auth primitives must bypass retry — they would either loop
|
||||
// (/refresh), or a 401 there genuinely means bad credentials (login,
|
||||
// register, oidc, device flows). User-data endpoints that happen to
|
||||
// live under /api/auth/ (me, me/image, change-password, app-passwords)
|
||||
// ARE retried so that an expired access token is transparently refreshed.
|
||||
// Public share endpoints (/api/s/) use 401 to mean "password required",
|
||||
// not "session expired" — intercepting them would wrongly redirect to login.
|
||||
if (urlStr.includes('/api/auth/') || urlStr.includes('/api/s/')) return response;
|
||||
const AUTH_PRIMITIVES = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/refresh',
|
||||
'/api/auth/register',
|
||||
'/api/auth/setup',
|
||||
'/api/auth/oidc/',
|
||||
'/api/auth/device/'
|
||||
];
|
||||
if (AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const refreshed = await _refresh();
|
||||
if (!refreshed) {
|
||||
|
||||
@@ -234,6 +234,10 @@ const OxiIcons = {
|
||||
576,
|
||||
'M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z'
|
||||
],
|
||||
link: [
|
||||
576,
|
||||
'M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z'
|
||||
],
|
||||
list: [
|
||||
512,
|
||||
'M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z'
|
||||
@@ -429,6 +433,10 @@ const OxiIcons = {
|
||||
'volume-up': [
|
||||
640,
|
||||
'M533.6 32.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C557.5 113.8 592 180.8 592 256s-34.5 142.2-88.7 186.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C598.5 426.7 640 346.2 640 256S598.5 85.2 533.6 32.5zM473.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C475.3 170.7 496 210.9 496 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C393.1 227.6 400 241 400 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C434.1 312.9 448 286.1 448 256s-13.9-56.9-35.4-74.5zM80 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L128 160 80 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48z'
|
||||
],
|
||||
world: [
|
||||
512,
|
||||
'M351.9 280l-190.9 0c2.9 64.5 17.2 123.9 37.5 167.4 11.4 24.5 23.7 41.8 35.1 52.4 11.2 10.5 18.9 12.2 22.9 12.2s11.7-1.7 22.9-12.2c11.4-10.6 23.7-28 35.1-52.4 20.3-43.5 34.6-102.9 37.5-167.4zM160.9 232l190.9 0C349 167.5 334.7 108.1 314.4 64.6 303 40.2 290.7 22.8 279.3 12.2 268.1 1.7 260.4 0 256.4 0s-11.7 1.7-22.9 12.2c-11.4 10.6-23.7 28-35.1 52.4-20.3 43.5-34.6 102.9-37.5 167.4zm-48 0C116.4 146.4 138.5 66.9 170.8 14.7 78.7 47.3 10.9 131.2 1.5 232l111.4 0zM1.5 280c9.4 100.8 77.2 184.7 169.3 217.3-32.3-52.2-54.4-131.7-57.9-217.3L1.5 280zm398.4 0c-3.5 85.6-25.6 165.1-57.9 217.3 92.1-32.7 159.9-116.5 169.3-217.3l-111.4 0zm111.4-48C501.9 131.2 434.1 47.3 342 14.7 374.3 66.9 396.4 146.4 399.9 232l111.4 0z'
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* System-users convenience layer.
|
||||
*
|
||||
* Thin wrapper over `addressBook.listContacts(SYSTEM_BOOK_ID)` that
|
||||
* provides a userId → display-name index. Used wherever a grant's
|
||||
* `granted_by` UUID needs to be shown as a human-readable name
|
||||
* (owner tooltips, share dialogs, etc.).
|
||||
* provides a userId → display-name index, a userId → photo-url index,
|
||||
* and a userId → primary-email index.
|
||||
* Used wherever a grant's `granted_by` UUID needs to be shown as a
|
||||
* human-readable name (owner tooltips, share dialogs, etc.), avatar
|
||||
* image (userVignette, user menu), or email (suggestion dropdowns).
|
||||
*
|
||||
* Falls back gracefully when the system address book is disabled
|
||||
* server-side (`OXICLOUD_EXPOSE_SYSTEM_USERS` not set): `isAvailable()`
|
||||
@@ -20,6 +22,12 @@ import { addressBook, SYSTEM_BOOK_ID } from './addressBook.js';
|
||||
/** @type {Map<string, string> | null} userId → display name, built lazily */
|
||||
let _index = null;
|
||||
|
||||
/** @type {Map<string, string | null> | null} userId → photo URL (or null), built lazily */
|
||||
let _photoIndex = null;
|
||||
|
||||
/** @type {Map<string, string | null> | null} userId → primary email (or null), built lazily */
|
||||
let _emailIndex = null;
|
||||
|
||||
/**
|
||||
* Derive the best human-readable name from a contact.
|
||||
* Priority: "First Last" → full_name → primary email → shortened id.
|
||||
@@ -36,7 +44,7 @@ function _nameFor(c) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the index is built (idempotent).
|
||||
* Ensure both indexes are built (idempotent).
|
||||
* After loading contacts from the system address book, the current user
|
||||
* (from localStorage) is injected so owner cells resolve correctly even
|
||||
* when the server-side address book does not include the logged-in user.
|
||||
@@ -46,15 +54,30 @@ async function _ensureIndex() {
|
||||
if (_index !== null) return;
|
||||
const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID);
|
||||
_index = new Map(contacts.map((c) => [c.id, _nameFor(c)]));
|
||||
_photoIndex = new Map(contacts.map((c) => [c.id, c.photo_url ?? null]));
|
||||
_emailIndex = new Map(
|
||||
contacts.map((c) => {
|
||||
const primary = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? null;
|
||||
return [c.id, primary];
|
||||
})
|
||||
);
|
||||
|
||||
// Inject the current user if they are not already in the index
|
||||
try {
|
||||
const raw = localStorage.getItem('oxicloud_user');
|
||||
if (raw) {
|
||||
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw));
|
||||
if (u?.id && !_index.has(u.id)) {
|
||||
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
|
||||
_index.set(u.id, name);
|
||||
const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string, image?:string|null}} */ (JSON.parse(raw));
|
||||
if (u?.id) {
|
||||
if (!_index.has(u.id)) {
|
||||
const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`;
|
||||
_index.set(u.id, name);
|
||||
}
|
||||
if (!_photoIndex.has(u.id)) {
|
||||
_photoIndex.set(u.id, u.image ?? null);
|
||||
}
|
||||
if (!_emailIndex.has(u.id)) {
|
||||
_emailIndex.set(u.id, u.email ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -85,6 +108,48 @@ async function getDisplayName(userId) {
|
||||
return _index?.get(userId) ?? `${userId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user UUID to a photo URL (or null if none set).
|
||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function getPhoto(userId) {
|
||||
await _ensureIndex();
|
||||
return _photoIndex?.get(userId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user UUID to their primary email address (or null if unknown).
|
||||
* Awaits the first load if not yet cached; subsequent calls resolve instantly.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function getEmail(userId) {
|
||||
await _ensureIndex();
|
||||
return _emailIndex?.get(userId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-refresh the current user's photo entry in the index from localStorage.
|
||||
* Call this after saving a new avatar on the profile page so that existing
|
||||
* vignettes can re-render without a full page reload.
|
||||
*/
|
||||
function refreshCurrentUserPhoto() {
|
||||
try {
|
||||
const raw = localStorage.getItem('oxicloud_user');
|
||||
if (!raw || !_photoIndex) return;
|
||||
const u = /** @type {{id?:string, image?:string|null}} */ (JSON.parse(raw));
|
||||
if (u?.id) {
|
||||
_photoIndex.set(u.id, u.image ?? null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `false` only after a confirmed 404 from the server (feature
|
||||
* disabled). Returns `true` when status is unknown or the book loaded OK.
|
||||
@@ -94,4 +159,4 @@ function isAvailable() {
|
||||
return addressBook.isSystemAvailable();
|
||||
}
|
||||
|
||||
export const systemUsers = { prefetch, getDisplayName, isAvailable };
|
||||
export const systemUsers = { prefetch, getDisplayName, getPhoto, getEmail, refreshCurrentUserPhoto, isAvailable };
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Client-side image resize utility.
|
||||
*
|
||||
* Accepts a File/Blob, resizes it to fit within maxSize × maxSize pixels
|
||||
* (never upscales), and returns a data URI (WebP preferred, JPEG fallback).
|
||||
*
|
||||
* Used by the profile page before uploading an avatar image so that
|
||||
* data URIs stay well within the 512 KiB backend limit.
|
||||
*/
|
||||
|
||||
/** Accepted MIME types for avatar uploads. */
|
||||
const ACCEPTED_TYPES = new Set(['image/png', 'image/webp', 'image/jpeg']);
|
||||
|
||||
/**
|
||||
* Load an image File/Blob as a data URL via FileReader.
|
||||
* @param {File | Blob} file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
function _readAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(/** @type {string} */ (reader.result));
|
||||
reader.onerror = () => reject(new Error('FileReader failed'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a data URL into an HTMLImageElement (waits for `onload`).
|
||||
* @param {string} src
|
||||
* @returns {Promise<HTMLImageElement>}
|
||||
*/
|
||||
function _loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Image failed to load'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a canvas to a data URI, preferring WebP at quality 0.85.
|
||||
* Falls back to JPEG if the browser does not support WebP encoding.
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @returns {string}
|
||||
*/
|
||||
function _canvasToDataUri(canvas) {
|
||||
const webp = canvas.toDataURL('image/webp', 0.85);
|
||||
// toDataURL returns a PNG if the MIME type is not supported — detect by prefix
|
||||
if (webp.startsWith('data:image/webp')) return webp;
|
||||
return canvas.toDataURL('image/jpeg', 0.85);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image File to fit within maxSize × maxSize, then return
|
||||
* a data URI (WebP at quality 0.85, or JPEG as fallback).
|
||||
*
|
||||
* - Images already within maxSize × maxSize are not upscaled.
|
||||
* - Only `image/png`, `image/webp`, and `image/jpeg` are accepted;
|
||||
* all other MIME types throw an Error.
|
||||
*
|
||||
* @param {File} file Image file to resize
|
||||
* @param {number} [maxSize=512] Maximum width and height in pixels
|
||||
* @returns {Promise<string>} data URI of the (possibly resized) image
|
||||
*/
|
||||
export async function resizeImageToDataUrl(file, maxSize = 104) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
throw new Error(`Unsupported image type: ${file.type}. Accepted: PNG, WebP, JPEG.`);
|
||||
}
|
||||
|
||||
const dataUrl = await _readAsDataUrl(file);
|
||||
const img = await _loadImage(dataUrl);
|
||||
|
||||
const { naturalWidth: w, naturalHeight: h } = img;
|
||||
|
||||
// Compute output dimensions — scale down proportionally if needed, never upscale
|
||||
let outW = w;
|
||||
let outH = h;
|
||||
if (w > maxSize || h > maxSize) {
|
||||
const ratio = Math.min(maxSize / w, maxSize / h);
|
||||
outW = Math.round(w * ratio);
|
||||
outH = Math.round(h * ratio);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Could not get 2D canvas context');
|
||||
ctx.drawImage(img, 0, 0, outW, outH);
|
||||
|
||||
return _canvasToDataUri(canvas);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { installFetchInterceptor } from '../../core/fetchWrapper.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { oxiIconsInit } from '../../core/icons.js';
|
||||
import { resizeImageToDataUrl } from '../../utils/imageResize.js';
|
||||
|
||||
// Install the fetch interceptor so expired access tokens are refreshed
|
||||
// automatically on this standalone page (it is not loaded by main.js here).
|
||||
installFetchInterceptor();
|
||||
|
||||
const API = '/api';
|
||||
|
||||
@@ -36,6 +42,240 @@ function timeAgo(dateStr) {
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ── Avatar helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the large profile avatar (#p-avatar) — photo or initials.
|
||||
* @param {string | null | undefined} photo
|
||||
* @param {string} initials
|
||||
*/
|
||||
function _renderAvatar(photo, initials) {
|
||||
const avatarEl = document.getElementById('p-avatar');
|
||||
if (!avatarEl) return;
|
||||
if (photo) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = initials;
|
||||
img.src = photo;
|
||||
img.onerror = () => {
|
||||
avatarEl.replaceChildren();
|
||||
avatarEl.textContent = initials;
|
||||
};
|
||||
avatarEl.replaceChildren(img);
|
||||
} else {
|
||||
avatarEl.replaceChildren();
|
||||
avatarEl.textContent = initials;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist user data to localStorage and refresh the top-right avatar.
|
||||
* Calls GET /api/auth/me to get the fresh user object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function _refreshUserCache() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/auth/me`, {
|
||||
headers: headers(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const user = await resp.json();
|
||||
localStorage.setItem('oxicloud_user', JSON.stringify(user));
|
||||
|
||||
// Refresh top-right avatars if userMenu module is loaded on this page
|
||||
// (profile.html is a standalone page, userMenu is only in index.html)
|
||||
// — so we update #user-avatar / #user-menu-avatar directly if present
|
||||
const initials = (user.username || '?').substring(0, 2).toUpperCase();
|
||||
const topEl = /** @type {HTMLElement|null} */ (document.getElementById('user-avatar'));
|
||||
const dropEl = /** @type {HTMLElement|null} */ (document.getElementById('user-menu-avatar'));
|
||||
if (topEl || dropEl) {
|
||||
/** @param {HTMLElement|null} el */
|
||||
function applyPhoto(el) {
|
||||
if (!el) return;
|
||||
if (user.image) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = initials;
|
||||
img.src = user.image;
|
||||
img.onerror = () => {
|
||||
el.replaceChildren();
|
||||
el.textContent = initials;
|
||||
};
|
||||
el.replaceChildren(img);
|
||||
} else {
|
||||
el.replaceChildren();
|
||||
el.textContent = initials;
|
||||
}
|
||||
}
|
||||
applyPhoto(topEl);
|
||||
applyPhoto(dropEl);
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ── Photo edit panel ────────────────────────────────────────────────────────────
|
||||
|
||||
/** @type {string|null} Pending data URI from file upload (upload mode) */
|
||||
let _uploadedDataUri = null;
|
||||
|
||||
/**
|
||||
* Switch the visible edit tab.
|
||||
* @param {'url'|'upload'} tab
|
||||
*/
|
||||
function _switchTab(tab) {
|
||||
const urlPane = document.getElementById('p-pane-url');
|
||||
const uploadPane = document.getElementById('p-pane-upload');
|
||||
const urlBtn = document.getElementById('p-tab-url');
|
||||
const uploadBtn = document.getElementById('p-tab-upload');
|
||||
if (tab === 'url') {
|
||||
urlPane?.classList.remove('hidden');
|
||||
uploadPane?.classList.add('hidden');
|
||||
urlBtn?.classList.add('active');
|
||||
uploadBtn?.classList.remove('active');
|
||||
} else {
|
||||
urlPane?.classList.add('hidden');
|
||||
uploadPane?.classList.remove('hidden');
|
||||
urlBtn?.classList.remove('active');
|
||||
uploadBtn?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function _openEditPanel() {
|
||||
document.getElementById('p-avatar-edit-panel')?.classList.remove('hidden');
|
||||
_switchTab('url');
|
||||
_uploadedDataUri = null;
|
||||
const preview = /** @type {HTMLImageElement|null} */ (document.getElementById('p-image-preview'));
|
||||
if (preview) {
|
||||
preview.src = '';
|
||||
preview.classList.add('hidden');
|
||||
}
|
||||
const urlInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-url'));
|
||||
if (urlInput) urlInput.value = '';
|
||||
const status = document.getElementById('p-avatar-status');
|
||||
if (status) status.innerHTML = '';
|
||||
}
|
||||
|
||||
function _closeEditPanel() {
|
||||
document.getElementById('p-avatar-edit-panel')?.classList.add('hidden');
|
||||
_uploadedDataUri = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send PUT /api/auth/me/image and update UI on success.
|
||||
* @param {string | null} image
|
||||
*/
|
||||
async function _saveImage(image) {
|
||||
const statusEl = document.getElementById('p-avatar-status');
|
||||
const saveBtn = /** @type {HTMLButtonElement|null} */ (document.getElementById('p-avatar-save'));
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i>`;
|
||||
}
|
||||
if (statusEl) statusEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/auth/me/image`, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ image })
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
await _refreshUserCache();
|
||||
// Update large avatar immediately
|
||||
const raw = localStorage.getItem('oxicloud_user');
|
||||
const user = raw ? JSON.parse(raw) : null;
|
||||
const initials = (user?.username || '?').substring(0, 2).toUpperCase();
|
||||
_renderAvatar(user?.image, initials);
|
||||
_closeEditPanel();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML =
|
||||
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
|
||||
escapeHtml(err.message || err.error || i18n.t('profile.photo_save_failed')) +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML =
|
||||
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
|
||||
escapeHtml(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message })) +
|
||||
'</div>';
|
||||
}
|
||||
} finally {
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('profile.photo_save'))}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _setupPhotoEdit() {
|
||||
const editBtn = document.getElementById('p-avatar-edit-btn');
|
||||
const cancelBtn = document.getElementById('p-avatar-cancel');
|
||||
const saveBtn = document.getElementById('p-avatar-save');
|
||||
const removeBtn = document.getElementById('p-avatar-remove');
|
||||
const tabUrl = document.getElementById('p-tab-url');
|
||||
const tabUpload = document.getElementById('p-tab-upload');
|
||||
const fileInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-file'));
|
||||
|
||||
editBtn?.addEventListener('click', _openEditPanel);
|
||||
cancelBtn?.addEventListener('click', _closeEditPanel);
|
||||
|
||||
tabUrl?.addEventListener('click', () => {
|
||||
_switchTab('url');
|
||||
});
|
||||
tabUpload?.addEventListener('click', () => {
|
||||
_switchTab('upload');
|
||||
});
|
||||
|
||||
saveBtn?.addEventListener('click', async () => {
|
||||
const activePane = document.getElementById('p-pane-url')?.classList.contains('hidden') ? 'upload' : 'url';
|
||||
if (activePane === 'url') {
|
||||
const urlInput = /** @type {HTMLInputElement|null} */ (document.getElementById('p-image-url'));
|
||||
const val = urlInput?.value.trim() || null;
|
||||
await _saveImage(val || null);
|
||||
} else {
|
||||
if (!_uploadedDataUri) {
|
||||
const status = document.getElementById('p-avatar-status');
|
||||
if (status)
|
||||
status.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.photo_no_file'))}</div>`;
|
||||
return;
|
||||
}
|
||||
await _saveImage(_uploadedDataUri);
|
||||
}
|
||||
});
|
||||
|
||||
removeBtn?.addEventListener('click', async () => {
|
||||
await _saveImage(null);
|
||||
});
|
||||
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const status = document.getElementById('p-avatar-status');
|
||||
if (status) status.innerHTML = '';
|
||||
try {
|
||||
const dataUri = await resizeImageToDataUrl(file, 104);
|
||||
_uploadedDataUri = dataUri;
|
||||
const preview = /** @type {HTMLImageElement|null} */ (document.getElementById('p-image-preview'));
|
||||
if (preview) {
|
||||
preview.src = dataUri;
|
||||
preview.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
_uploadedDataUri = null;
|
||||
if (status) {
|
||||
status.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(/** @type {Error} */ (err).message)}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
oxiIconsInit();
|
||||
@@ -50,7 +290,7 @@ async function init() {
|
||||
const user = await resp.json();
|
||||
|
||||
const initials = (user.username || '?').substring(0, 2).toUpperCase();
|
||||
document.getElementById('p-avatar').textContent = initials;
|
||||
_renderAvatar(user.image, initials);
|
||||
document.getElementById('p-username').textContent = user.username;
|
||||
document.getElementById('p-email').textContent = user.email || '';
|
||||
|
||||
@@ -63,6 +303,17 @@ async function init() {
|
||||
badge.innerHTML = `<i class="fas fa-user"></i> ${i18n.t('profile.role_user')}`;
|
||||
}
|
||||
|
||||
// Photo edit controls
|
||||
const isLocal = !user.auth_provider || user.auth_provider === 'local';
|
||||
const editBtn = document.getElementById('p-avatar-edit-btn');
|
||||
const oidcNote = document.getElementById('p-avatar-oidc-note');
|
||||
if (user.can_edit_image && isLocal) {
|
||||
editBtn?.classList.remove('hidden');
|
||||
} else if (!isLocal && user.image) {
|
||||
// OIDC user with a photo: show note, no edit button
|
||||
oidcNote?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('p-detail-username').textContent = user.username;
|
||||
document.getElementById('p-detail-email').textContent = user.email || '—';
|
||||
document.getElementById('p-detail-role').textContent = user.role === 'admin' ? i18n.t('profile.role_admin') : i18n.t('profile.role_user');
|
||||
@@ -361,6 +612,9 @@ document.getElementById('app-pw-generate').addEventListener('click', createAppPa
|
||||
document.getElementById('app-pw-copy-btn').addEventListener('click', copyAppPassword);
|
||||
document.getElementById('app-pw-auto-toggle').addEventListener('click', toggleAutoPasswords);
|
||||
|
||||
/* Photo-edit panel — wired once at module load, not per init() call */
|
||||
_setupPhotoEdit();
|
||||
|
||||
/* Re-render when language changes */
|
||||
window.addEventListener('translationsLoaded', () => {
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user