Add member editing and profile pictures

This commit is contained in:
Rafael Foster
2026-04-27 08:09:00 -03:00
parent b82a86c4b3
commit 6e410cb671
26 changed files with 737 additions and 21 deletions
+1 -1
View File
@@ -362,7 +362,7 @@ function renderFamilyWidget(users) {
const visible = users.slice(0, 6);
const avatars = visible.map((u) => `
<span class="family-widget-avatar" style="background:${esc(u.avatar_color || '#64748b')}" title="${esc(u.display_name)}">
${esc(initials(u.display_name))}
${u.avatar_data ? `<img src="${esc(u.avatar_data)}" alt="${esc(u.display_name)}" loading="lazy">` : esc(initials(u.display_name))}
</span>
`).join('');
+279 -7
View File
@@ -5,7 +5,7 @@
*/
import { api, auth } from '/api.js';
import { confirmModal } from '/components/modal.js';
import { openModal, closeModal, confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
@@ -15,6 +15,7 @@ const SETTINGS_TAB_KEY = 'oikos:settings:tab';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
const DEFAULT_APP_NAME = 'Oikos';
const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'];
const MAX_AVATAR_DATA_LENGTH = 768 * 1024;
const CATEGORY_I18N = {
'Obst & Gemüse': 'shopping.catFruitVeg',
@@ -55,6 +56,83 @@ function buildFamilyRoleOptions(selected = 'other') {
`).join('');
}
function avatarHtml(user, className = 'settings-avatar') {
const safeName = esc(user?.display_name || '');
const fallback = esc(initials(user?.display_name || ''));
const bg = esc(user?.avatar_color || '#007AFF');
return `
<div class="${className}" style="background:${bg}" title="${safeName}">
${user?.avatar_data ? `<img src="${esc(user.avatar_data)}" alt="${safeName}" loading="lazy">` : fallback}
</div>
`;
}
function avatarEditorHtml(user, prefix) {
return `
<div class="settings-avatar-editor">
<div class="settings-avatar-preview" id="${prefix}-avatar-preview">
${avatarHtml(user, 'settings-avatar settings-avatar--lg')}
</div>
<div class="settings-avatar-editor__controls">
<label class="form-label" for="${prefix}-avatar-file">${t('settings.profilePictureLabel')}</label>
<input class="form-input" type="file" id="${prefix}-avatar-file" accept="image/png,image/jpeg,image/webp" />
<p class="form-hint">${t('settings.profilePictureHint')}</p>
<button type="button" class="btn btn--secondary" id="${prefix}-avatar-remove">${t('settings.profilePictureRemove')}</button>
</div>
</div>
`;
}
function setAvatarPreview(container, selector, user) {
const preview = container.querySelector(selector);
if (!preview) return;
preview.replaceChildren();
preview.insertAdjacentHTML('beforeend', avatarHtml(user, 'settings-avatar settings-avatar--lg'));
}
function readImageAsDataUrl(file) {
return new Promise((resolve, reject) => {
if (!file) return resolve(undefined);
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
return reject(new Error(t('settings.profilePictureTypeError')));
}
if (file.size > 5 * 1024 * 1024) {
return reject(new Error(t('settings.profilePictureFileTooLarge')));
}
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
try {
const maxSize = 512;
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const width = Math.max(1, Math.round(img.width * scale));
const height = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.86);
URL.revokeObjectURL(objectUrl);
if (dataUrl.length > MAX_AVATAR_DATA_LENGTH) {
reject(new Error(t('settings.profilePictureTooLarge')));
} else {
resolve(dataUrl);
}
} catch (err) {
URL.revokeObjectURL(objectUrl);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error(t('settings.profilePictureReadError')));
};
img.src = objectUrl;
});
}
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
@@ -397,9 +475,7 @@ export async function render(container, { user }) {
<div class="settings-card">
<div class="settings-user-info">
<div class="settings-avatar" style="background:${esc(user?.avatar_color) || '#007AFF'}">
${esc(initials(user?.display_name))}
</div>
${avatarHtml(user)}
<div>
<div class="settings-user-info__name">${esc(user?.display_name)}</div>
<div class="settings-user-info__username">@${esc(user?.username)}</div>
@@ -407,6 +483,25 @@ export async function render(container, { user }) {
</div>
</div>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.profilePictureTitle')}</h3>
<form id="profile-form" class="settings-form">
${avatarEditorHtml(user, 'profile')}
<div class="form-group">
<label class="form-label" for="profile-display-name">${t('settings.displayNameLabel')}</label>
<input class="form-input" type="text" id="profile-display-name" maxlength="128" value="${esc(user?.display_name || '')}" required />
</div>
<div class="form-group">
<label class="form-label" for="profile-avatar-color">${t('settings.colorLabel')}</label>
<input class="form-input form-input--color" type="color" id="profile-avatar-color" value="${esc(user?.avatar_color || '#007AFF')}" />
</div>
<div id="profile-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="submit" class="btn btn--primary">${t('common.save')}</button>
</div>
</form>
</div>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.changePassword')}</h3>
<form id="password-form" class="settings-form">
@@ -522,14 +617,14 @@ export async function render(container, { user }) {
});
}
bindEvents(container, user, categories, icsSubscriptions, apiTokens);
bindEvents(container, user, users, categories, icsSubscriptions, apiTokens);
}
// --------------------------------------------------------
// Event-Binding
// --------------------------------------------------------
function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
bindTabEvents(container);
bindCategoryEvents(container);
bindIcsEvents(container, user, icsSubscriptions);
@@ -632,6 +727,64 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
});
}
const profileState = { avatarData: user?.avatar_data ?? null };
const profileAvatarFile = container.querySelector('#profile-avatar-file');
if (profileAvatarFile) {
profileAvatarFile.addEventListener('change', async () => {
const errorEl = container.querySelector('#profile-error');
errorEl.hidden = true;
try {
const avatarData = await readImageAsDataUrl(profileAvatarFile.files?.[0]);
if (avatarData !== undefined) {
profileState.avatarData = avatarData;
setAvatarPreview(container, '#profile-avatar-preview', {
display_name: container.querySelector('#profile-display-name')?.value || user?.display_name,
avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color,
avatar_data: avatarData,
});
}
} catch (err) {
profileAvatarFile.value = '';
showError(errorEl, err.message ?? t('common.errorGeneric'));
}
});
}
container.querySelector('#profile-avatar-remove')?.addEventListener('click', () => {
profileState.avatarData = null;
if (profileAvatarFile) profileAvatarFile.value = '';
setAvatarPreview(container, '#profile-avatar-preview', {
display_name: container.querySelector('#profile-display-name')?.value || user?.display_name,
avatar_color: container.querySelector('#profile-avatar-color')?.value || user?.avatar_color,
avatar_data: null,
});
});
const profileForm = container.querySelector('#profile-form');
if (profileForm) {
profileForm.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = container.querySelector('#profile-error');
const btn = profileForm.querySelector('[type=submit]');
errorEl.hidden = true;
btn.disabled = true;
try {
const res = await auth.updateProfile({
display_name: container.querySelector('#profile-display-name').value.trim(),
avatar_color: container.querySelector('#profile-avatar-color').value,
avatar_data: profileState.avatarData,
});
Object.assign(user, res.user);
window.oikos?.showToast(t('settings.profileSavedToast'), 'success');
render(container, { user });
} catch (err) {
showError(errorEl, err.message ?? t('common.errorGeneric'));
} finally {
btn.disabled = false;
}
});
}
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {
@@ -797,12 +950,14 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
try {
const res = await auth.createUser(data);
const list = container.querySelector('#members-list');
users.push(res.user);
list.insertAdjacentHTML('beforeend', memberHtml(res.user));
addMemberForm.reset();
container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
container.querySelector('#add-member-btn').hidden = false;
window.oikos?.showToast(t('settings.memberAddedToast', { name: res.user.display_name }), 'success');
bindDeleteButtons(container, user);
bindEditButtons(container, user, users);
} catch (err) {
showError(errorEl, err.message);
} finally {
@@ -812,6 +967,7 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
}
bindDeleteButtons(container, user);
bindEditButtons(container, user, users);
// Abmelden
const logoutBtn = container.querySelector('#logout-btn');
@@ -874,6 +1030,119 @@ function bindDeleteButtons(container, user) {
});
}
function bindEditButtons(container, currentUser, users) {
container.querySelectorAll('[data-edit-user]').forEach((btn) => {
btn.replaceWith(btn.cloneNode(true));
});
container.querySelectorAll('[data-edit-user]').forEach((btn) => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.editUser, 10);
const member = users.find((u) => u.id === id);
if (member) openEditMemberModal(member, currentUser, users, container);
});
});
}
function openEditMemberModal(member, currentUser, users, container) {
const state = { avatarData: member.avatar_data ?? null };
openModal({
title: t('settings.editMemberTitle'),
size: 'md',
content: `
<form id="edit-member-form" class="settings-form">
${avatarEditorHtml(member, 'edit-member')}
<div class="form-group">
<label class="form-label" for="edit-member-username">${t('settings.usernameLabel')}</label>
<input class="form-input" type="text" id="edit-member-username" value="${esc(member.username)}" required autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-display-name">${t('settings.displayNameLabel')}</label>
<input class="form-input" type="text" id="edit-member-display-name" value="${esc(member.display_name)}" required maxlength="128" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-avatar-color">${t('settings.colorLabel')}</label>
<input class="form-input form-input--color" type="color" id="edit-member-avatar-color" value="${esc(member.avatar_color || '#007AFF')}" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-family-role">${t('settings.familyRoleLabel')}</label>
<select class="form-input" id="edit-member-family-role">
${buildFamilyRoleOptions(member.family_role)}
</select>
</div>
<label class="toggle-row">
<input type="checkbox" id="edit-member-system-admin" ${member.role === 'admin' ? 'checked' : ''} />
<span>${t('settings.systemAdminLabel')}</span>
</label>
<p class="form-hint">${t('settings.systemAdminHint')}</p>
<div id="edit-member-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="button" class="btn btn--secondary" id="edit-member-cancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn--primary">${t('settings.saveMember')}</button>
</div>
</form>
`,
onSave(panel) {
const fileInput = panel.querySelector('#edit-member-avatar-file');
const errorEl = panel.querySelector('#edit-member-error');
fileInput?.addEventListener('change', async () => {
errorEl.hidden = true;
try {
const avatarData = await readImageAsDataUrl(fileInput.files?.[0]);
if (avatarData !== undefined) {
state.avatarData = avatarData;
setAvatarPreview(panel, '#edit-member-avatar-preview', {
display_name: panel.querySelector('#edit-member-display-name')?.value || member.display_name,
avatar_color: panel.querySelector('#edit-member-avatar-color')?.value || member.avatar_color,
avatar_data: avatarData,
});
}
} catch (err) {
fileInput.value = '';
showError(errorEl, err.message ?? t('common.errorGeneric'));
}
});
panel.querySelector('#edit-member-avatar-remove')?.addEventListener('click', () => {
state.avatarData = null;
if (fileInput) fileInput.value = '';
setAvatarPreview(panel, '#edit-member-avatar-preview', {
display_name: panel.querySelector('#edit-member-display-name')?.value || member.display_name,
avatar_color: panel.querySelector('#edit-member-avatar-color')?.value || member.avatar_color,
avatar_data: null,
});
});
panel.querySelector('#edit-member-cancel')?.addEventListener('click', closeModal);
panel.querySelector('#edit-member-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = panel.querySelector('[type=submit]');
errorEl.hidden = true;
submitBtn.disabled = true;
try {
const res = await auth.updateUser(member.id, {
username: panel.querySelector('#edit-member-username').value.trim(),
display_name: panel.querySelector('#edit-member-display-name').value.trim(),
avatar_color: panel.querySelector('#edit-member-avatar-color').value,
avatar_data: state.avatarData,
family_role: panel.querySelector('#edit-member-family-role').value,
system_admin: panel.querySelector('#edit-member-system-admin').checked,
});
const idx = users.findIndex((u) => u.id === member.id);
if (idx !== -1) users[idx] = res.user;
if (currentUser.id === member.id) Object.assign(currentUser, res.user);
closeModal();
window.oikos?.showToast(t('settings.memberUpdatedToast', { name: res.user.display_name }), 'success');
render(container, { user: currentUser });
} catch (err) {
showError(errorEl, err.message ?? t('common.errorGeneric'));
} finally {
submitBtn.disabled = false;
}
});
},
});
}
function apiTokenHtml(token) {
const status = token.revoked_at
? t('settings.apiTokenRevoked')
@@ -1123,11 +1392,14 @@ function memberHtml(u) {
const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : '';
return `
<li class="settings-member" data-id="${u.id}">
<div class="settings-avatar settings-avatar--sm" style="background:${esc(u.avatar_color)}">${initials(u.display_name)}</div>
${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
<div class="settings-member__info">
<span class="settings-member__name">${esc(u.display_name)}</span>
<span class="settings-member__meta">@${esc(u.username)} · ${esc(familyRole)}${systemRole}</span>
</div>
<button class="btn btn--icon btn--secondary" data-edit-user="${u.id}" aria-label="${esc(u.display_name)} ${t('settings.editMemberLabel')}" title="${t('settings.editMemberLabel')}">
<i data-lucide="edit-2" aria-hidden="true"></i>
</button>
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${esc(u.display_name)}" aria-label="${esc(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
<i data-lucide="trash-2" aria-hidden="true"></i>
</button>