feat(user-avatar): users can now edit there image (image is taken from OIDC picture)
This commit is contained in:
@@ -132,30 +132,10 @@
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.smd-suggestion-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smd-suggestion-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-heading);
|
||||
/* Vignette fills the row; name + email stack vertically inside .user-vignette__info. */
|
||||
.smd-suggestion-item .user-vignette {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.smd-suggestion-email {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-faint);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Role picker beside the search box */
|
||||
@@ -201,18 +181,6 @@
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.smd-chip-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smd-chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -280,25 +248,10 @@
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.smd-member-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.smd-member-name {
|
||||
/* Vignette grows to fill the member row; role-select + action stay right-aligned. */
|
||||
.smd-member-row .user-vignette {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-heading);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smd-member-role-select {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.user-avatar-btn:hover .user-avatar {
|
||||
.user-avatar-btn:hover .user-vignette__avatar {
|
||||
box-shadow: 0 0 0 2px var(--color-accent-ring-strong);
|
||||
}
|
||||
|
||||
@@ -28,21 +28,22 @@
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: var(--color-accent-gradient);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-danger-text);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
/* ── Avatar vignette overrides ────────────────────────────────────────────── */
|
||||
|
||||
/* The toolbar button and dropdown header mount avatar-only userVignette
|
||||
components. Sizing is owned by userVignette.css (--menu / --xl variants);
|
||||
the rules below add the decoration that is specific to this context. */
|
||||
|
||||
.user-avatar-btn .user-vignette__avatar {
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-menu-header .user-vignette__avatar {
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 12px var(--color-accent-shadow);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -89,42 +90,22 @@
|
||||
border-bottom: 1px solid var(--color-user-menu-header-border);
|
||||
}
|
||||
|
||||
.user-menu-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
background: var(--color-accent-gradient);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-danger-text);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 12px var(--color-accent-shadow);
|
||||
/* The header vignette (xl, name + email) fills the available width. */
|
||||
.user-menu-header .user-vignette {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu-info {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-menu-name {
|
||||
font-weight: 600;
|
||||
/* Increase name prominence relative to the base vignette style. */
|
||||
.user-menu-header .user-vignette__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-heading);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu-email {
|
||||
.user-menu-header .user-vignette__email {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.user-menu-storage {
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* name resolved asynchronously. Used in:
|
||||
* • Owner column (list view, SharedWithMe & Favorites sections)
|
||||
* • ShareModal member rows, chips, suggestion items
|
||||
* • User-menu toolbar button and dropdown header (avatar-only mode)
|
||||
*
|
||||
* Sizes: --xs (20 px) · --sm (24 px) · --md (32 px) · --lg (40 px)
|
||||
* Sizes: --xs (20 px) · --sm (24 px) · --list (36 px) · --md (32 px)
|
||||
* --lg (40 px) · --menu (38 px) · --xl (48 px)
|
||||
* Colours: .uv-color-0..4 (applied by JS via _colorIndex(userId) % 5)
|
||||
*
|
||||
* All colours use CSS custom properties — no raw hex / rgb / named values.
|
||||
@@ -40,6 +42,26 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Email mode (showEmail: true) ─────────────────────────────────────────── */
|
||||
|
||||
/* Column wrapper used when both name and email are shown. */
|
||||
.user-vignette__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-vignette__email {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Size variants ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.user-vignette--xs .user-vignette__avatar {
|
||||
@@ -54,6 +76,12 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-vignette--list .user-vignette__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-vignette--md .user-vignette__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -66,6 +94,18 @@
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.user-vignette--menu .user-vignette__avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-vignette--xl .user-vignette__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* ── Colour palette ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Colours are shared with the ShareModal avatar palette.
|
||||
@@ -95,3 +135,15 @@
|
||||
background: var(--color-badge-amber-bg);
|
||||
color: var(--color-badge-amber-text);
|
||||
}
|
||||
|
||||
/* ── Photo rendering ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* When a photo is available the JS replaces the initials text with an <img>.
|
||||
The avatar <span> keeps its background color as a fallback while loading. */
|
||||
.user-vignette__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -776,3 +776,160 @@ body {
|
||||
margin: 12px 0 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Avatar photo ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.avatar-large-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Fill circle with photo when JS injects an <img> */
|
||||
.avatar-large img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Pencil button that appears on hover (visible only for local accounts) */
|
||||
.avatar-edit-btn {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-bg-surface);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-sidebar-text-active);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.avatar-edit-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* OIDC note shown instead of edit button */
|
||||
.avatar-oidc-note {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-faint);
|
||||
margin-top: 6px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Photo edit panel ──────────────────────────────────────────────────────── */
|
||||
|
||||
.avatar-edit-panel {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.avatar-edit-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-tab {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
background: none;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.avatar-tab.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-sidebar-text-active);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.avatar-tab-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-url-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-alt);
|
||||
color: var(--color-text-dark);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar-url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-ring);
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* Hidden file input — triggered via the label */
|
||||
.avatar-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.avatar-file-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-alt);
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.avatar-file-label:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Live preview thumbnail (square, small) */
|
||||
.avatar-preview {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.avatar-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
#p-avatar-status {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
+2
-6
@@ -164,15 +164,11 @@
|
||||
|
||||
<div class="user-menu-wrapper" id="user-menu-wrapper">
|
||||
<button class="user-avatar-btn" id="user-avatar-btn">
|
||||
<div class="user-avatar" id="user-avatar">AD</div>
|
||||
<!-- avatar-only userVignette mounted here by updateUserMenuData() -->
|
||||
</button>
|
||||
<div class="user-menu" id="user-menu">
|
||||
<div class="user-menu-header">
|
||||
<div class="user-menu-avatar" id="user-menu-avatar">AD</div>
|
||||
<div class="user-menu-info">
|
||||
<div class="user-menu-name" id="user-menu-name">User</div>
|
||||
<div class="user-menu-email" id="user-menu-email">user@oxicloud.app</div>
|
||||
</div>
|
||||
<!-- userVignette (avatar + name + email) mounted here by updateUserMenuData() -->
|
||||
</div>
|
||||
<div class="user-menu-role-badge hidden" id="user-menu-role-badge">
|
||||
<span class="role-badge role-badge-admin"><i class="fas fa-shield-alt"></i> Admin</span>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "أدخل تسمية",
|
||||
"error_create_pw": "فشل إنشاء كلمة المرور",
|
||||
"confirm_revoke": "إلغاء كلمة المرور \"{{label}}\"؟ ستتوقف العملاء عن العمل.",
|
||||
"error_revoke": "فشل الإلغاء"
|
||||
"error_revoke": "فشل الإلغاء",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "جارٍ الرفع...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Bitte Bezeichnung eingeben",
|
||||
"error_create_pw": "App-Passwort erstellen fehlgeschlagen",
|
||||
"confirm_revoke": "App-Passwort \"{{label}}\" widerrufen? Clients werden nicht mehr funktionieren.",
|
||||
"error_revoke": "Widerrufen fehlgeschlagen"
|
||||
"error_revoke": "Widerrufen fehlgeschlagen",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Wird hochgeladen...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Please enter a label",
|
||||
"error_create_pw": "Failed to create app password",
|
||||
"confirm_revoke": "Revoke app password \"{{label}}\"? Clients using this password will stop working.",
|
||||
"error_revoke": "Failed to revoke app password"
|
||||
"error_revoke": "Failed to revoke app password",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Uploading...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Introduce una etiqueta",
|
||||
"error_create_pw": "Error al crear contraseña de aplicación",
|
||||
"confirm_revoke": "¿Revocar contraseña \"{{label}}\"? Los clientes que la usen dejarán de funcionar.",
|
||||
"error_revoke": "Error al revocar contraseña"
|
||||
"error_revoke": "Error al revocar contraseña",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Subiendo...",
|
||||
|
||||
+14
-1
@@ -660,7 +660,20 @@
|
||||
"error_label_required": "لطفاً برچسب وارد کنید",
|
||||
"error_create_pw": "ایجاد رمز ناموفق بود",
|
||||
"confirm_revoke": "رمز «{{label}}» ابطال شود؟ کلاینتها از کار میافتند.",
|
||||
"error_revoke": "ابطال ناموفق بود"
|
||||
"error_revoke": "ابطال ناموفق بود",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "فایل تغییر نام داد",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Veuillez entrer un libellé",
|
||||
"error_create_pw": "Impossible de créer le mot de passe",
|
||||
"confirm_revoke": "Révoquer le mot de passe \"{{label}}\" ? Les clients l'utilisant ne fonctionneront plus.",
|
||||
"error_revoke": "Échec de la révocation"
|
||||
"error_revoke": "Échec de la révocation",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Téléchargement en cours...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "कृपया एक लेबल दर्ज करें",
|
||||
"error_create_pw": "ऐप पासवर्ड बनाने में विफल",
|
||||
"confirm_revoke": "ऐप पासवर्ड \"{{label}}\" रद्द करें? इसका उपयोग करने वाले क्लाइंट काम करना बंद कर देंगे।",
|
||||
"error_revoke": "रद्द करने में विफल"
|
||||
"error_revoke": "रद्द करने में विफल",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "अपलोड हो रहा है...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Inserisci un'etichetta",
|
||||
"error_create_pw": "Impossibile creare la password",
|
||||
"confirm_revoke": "Revocare la password \"{{label}}\"? I client che la usano smetteranno di funzionare.",
|
||||
"error_revoke": "Revoca fallita"
|
||||
"error_revoke": "Revoca fallita",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Caricamento in corso...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "ラベルを入力してください",
|
||||
"error_create_pw": "アプリパスワードの作成に失敗しました",
|
||||
"confirm_revoke": "アプリパスワード「{{label}}」を失効させますか?使用中のクライアントは動作しなくなります。",
|
||||
"error_revoke": "失効に失敗しました"
|
||||
"error_revoke": "失効に失敗しました",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "アップロード中...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "라벨을 입력하세요",
|
||||
"error_create_pw": "앱 비밀번호 생성 실패",
|
||||
"confirm_revoke": "앱 비밀번호 \"{{label}}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 클라이언트가 작동하지 않게 됩니다.",
|
||||
"error_revoke": "취소 실패"
|
||||
"error_revoke": "취소 실패",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "업로드 중...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Voer een label in",
|
||||
"error_create_pw": "App-wachtwoord aanmaken mislukt",
|
||||
"confirm_revoke": "App-wachtwoord \"{{label}}\" intrekken? Clients die dit wachtwoord gebruiken zullen stoppen.",
|
||||
"error_revoke": "Intrekken mislukt"
|
||||
"error_revoke": "Intrekken mislukt",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Bezig met uploaden...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Wprowadź etykietę",
|
||||
"error_create_pw": "Nie udało się utworzyć hasła aplikacji",
|
||||
"confirm_revoke": "Unieważnić hasło aplikacji \"{{label}}\"? Klienci używający tego hasła przestaną działać.",
|
||||
"error_revoke": "Nie udało się unieważnić hasła aplikacji"
|
||||
"error_revoke": "Nie udało się unieważnić hasła aplikacji",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Przesyłanie...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Digite um rótulo",
|
||||
"error_create_pw": "Falha ao criar senha de aplicativo",
|
||||
"confirm_revoke": "Revogar senha \"{{label}}\"? Clientes que usam esta senha deixarão de funcionar.",
|
||||
"error_revoke": "Falha ao revogar"
|
||||
"error_revoke": "Falha ao revogar",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "A carregar...",
|
||||
|
||||
+14
-1
@@ -677,7 +677,20 @@
|
||||
"error_label_required": "Введите метку",
|
||||
"error_create_pw": "Не удалось создать пароль",
|
||||
"confirm_revoke": "Отозвать пароль «{{label}}»? Клиенты перестанут работать.",
|
||||
"error_revoke": "Не удалось отозвать"
|
||||
"error_revoke": "Не удалось отозвать",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "Загрузка...",
|
||||
|
||||
@@ -660,7 +660,20 @@
|
||||
"error_label_required": "請輸入標籤",
|
||||
"error_create_pw": "建立應用密碼失敗",
|
||||
"confirm_revoke": "撤銷應用密碼\"{{label}}\"?使用此密碼的客戶端將停止工作。",
|
||||
"error_revoke": "撤銷失敗"
|
||||
"error_revoke": "撤銷失敗",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "檔案已重新命名",
|
||||
|
||||
+14
-1
@@ -660,7 +660,20 @@
|
||||
"error_label_required": "请输入标签",
|
||||
"error_create_pw": "创建应用密码失败",
|
||||
"confirm_revoke": "撤销应用密码\"{{label}}\"?使用此密码的客户端将停止工作。",
|
||||
"error_revoke": "撤销失败"
|
||||
"error_revoke": "撤销失败",
|
||||
"edit_photo": "Edit photo",
|
||||
"photo_tab_url": "URL",
|
||||
"photo_tab_upload": "Upload",
|
||||
"photo_url_placeholder": "https://example.com/photo.jpg",
|
||||
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
|
||||
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
|
||||
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
|
||||
"photo_save": "Save photo",
|
||||
"photo_remove": "Remove photo",
|
||||
"photo_cancel": "Cancel",
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider."
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "文件已重命名",
|
||||
|
||||
+42
-1
@@ -40,13 +40,54 @@
|
||||
<div id="main-content" class="hidden">
|
||||
<div class="profile-card">
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-large" id="p-avatar">—</div>
|
||||
<div class="avatar-large-wrap">
|
||||
<div class="avatar-large" id="p-avatar">—</div>
|
||||
<button class="avatar-edit-btn hidden" id="p-avatar-edit-btn" data-i18n-title="profile.edit_photo" title="Edit photo">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="avatar-info">
|
||||
<h1 id="p-username">—</h1>
|
||||
<div class="email" id="p-email">—</div>
|
||||
<span class="role-badge" id="p-role-badge">—</span>
|
||||
<p class="avatar-oidc-note hidden" id="p-avatar-oidc-note" data-i18n="profile.photo_managed_by_oidc">Photo managed by your identity provider.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo edit panel (local accounts only) -->
|
||||
<div class="avatar-edit-panel hidden" id="p-avatar-edit-panel">
|
||||
<div class="avatar-edit-tabs">
|
||||
<button class="avatar-tab active" id="p-tab-url" data-i18n="profile.photo_tab_url">URL</button>
|
||||
<button class="avatar-tab" id="p-tab-upload" data-i18n="profile.photo_tab_upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<!-- URL input mode -->
|
||||
<div class="avatar-tab-pane" id="p-pane-url">
|
||||
<input type="url" id="p-image-url" class="avatar-url-input"
|
||||
data-i18n-placeholder="profile.photo_url_placeholder"
|
||||
placeholder="https://example.com/photo.jpg">
|
||||
<small class="avatar-hint" data-i18n="profile.photo_url_hint">https://, http://, or data:image/…;base64,… accepted</small>
|
||||
</div>
|
||||
|
||||
<!-- Upload mode -->
|
||||
<div class="avatar-tab-pane hidden" id="p-pane-upload">
|
||||
<label class="avatar-file-label" for="p-image-file">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<span data-i18n="profile.photo_choose_file">Choose a photo (PNG, JPEG, WebP)</span>
|
||||
</label>
|
||||
<input type="file" id="p-image-file" class="avatar-file-input"
|
||||
accept="image/png,image/jpeg,image/webp">
|
||||
<img class="avatar-preview hidden" id="p-image-preview" alt="Preview" src="">
|
||||
<small class="avatar-hint" data-i18n="profile.photo_resize_note">Images larger than 512 × 512 px are automatically resized.</small>
|
||||
</div>
|
||||
|
||||
<div class="avatar-edit-actions">
|
||||
<button class="btn btn-primary" id="p-avatar-save" data-i18n="profile.photo_save">Save</button>
|
||||
<button class="btn btn-danger-sm" id="p-avatar-remove" data-i18n="profile.photo_remove">Remove photo</button>
|
||||
<button class="btn btn-secondary" id="p-avatar-cancel" data-i18n="profile.photo_cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="p-avatar-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
|
||||
Reference in New Issue
Block a user