feat(user-avatar): users can now edit there image (image is taken from OIDC picture)

This commit is contained in:
Edouard Vanbelle
2026-05-26 00:50:38 +02:00
parent 8b6c8e345b
commit b0c5e7827e
43 changed files with 1563 additions and 250 deletions
+6 -45
View File
@@ -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;
+91 -23
View File
@@ -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;
}