Add member editing and profile pictures
This commit is contained in:
@@ -138,6 +138,8 @@ const auth = {
|
||||
me: () => api.get('/auth/me'),
|
||||
getUsers: () => api.get('/auth/users'),
|
||||
createUser: (data) => api.post('/auth/users', data),
|
||||
updateUser: (id, data) => api.patch(`/auth/users/${id}`, data),
|
||||
updateProfile: (data) => api.patch('/auth/me/profile', data),
|
||||
deleteUser: (id) => api.delete(`/auth/users/${id}`),
|
||||
};
|
||||
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "الاسم المعروض",
|
||||
"memberPasswordLabel": "كلمة المرور",
|
||||
"colorLabel": "اللون",
|
||||
"profilePictureTitle": "صورة الملف الشخصي",
|
||||
"profilePictureLabel": "تحميل صورة",
|
||||
"profilePictureHint": "PNG أو JPEG أو WebP. يتم تصغير الصور الكبيرة قبل الرفع.",
|
||||
"profilePictureRemove": "إزالة الصورة",
|
||||
"profilePictureTypeError": "استخدم صورة PNG أو JPEG أو WebP.",
|
||||
"profilePictureFileTooLarge": "ملف الصورة كبير جدًا.",
|
||||
"profilePictureTooLarge": "ما زالت صورة الملف الشخصي كبيرة جدًا بعد التصغير.",
|
||||
"profilePictureReadError": "تعذرت قراءة الصورة المحددة.",
|
||||
"profileSavedToast": "تم تحديث الملف الشخصي.",
|
||||
"editMemberLabel": "تعديل",
|
||||
"editMemberTitle": "تعديل فرد العائلة",
|
||||
"saveMember": "حفظ الفرد",
|
||||
"memberUpdatedToast": "تم تحديث {{name}}.",
|
||||
"familyRoleLabel": "دور العائلة",
|
||||
"familyRoleDad": "الأب",
|
||||
"familyRoleMom": "الأم",
|
||||
|
||||
@@ -630,6 +630,19 @@
|
||||
"displayNameLabel": "Anzeigename",
|
||||
"memberPasswordLabel": "Passwort",
|
||||
"colorLabel": "Farbe",
|
||||
"profilePictureTitle": "Profilbild",
|
||||
"profilePictureLabel": "Bild hochladen",
|
||||
"profilePictureHint": "PNG, JPEG oder WebP. Große Bilder werden vor dem Hochladen verkleinert.",
|
||||
"profilePictureRemove": "Bild entfernen",
|
||||
"profilePictureTypeError": "Bitte ein PNG-, JPEG- oder WebP-Bild verwenden.",
|
||||
"profilePictureFileTooLarge": "Die Bilddatei ist zu groß.",
|
||||
"profilePictureTooLarge": "Das Profilbild ist nach dem Verkleinern noch zu groß.",
|
||||
"profilePictureReadError": "Das ausgewählte Bild konnte nicht gelesen werden.",
|
||||
"profileSavedToast": "Profil aktualisiert.",
|
||||
"editMemberLabel": "Bearbeiten",
|
||||
"editMemberTitle": "Familienmitglied bearbeiten",
|
||||
"saveMember": "Mitglied speichern",
|
||||
"memberUpdatedToast": "{{name}} aktualisiert.",
|
||||
"familyRoleLabel": "Familienrolle",
|
||||
"familyRoleDad": "Vater",
|
||||
"familyRoleMom": "Mutter",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Εμφανιζόμενο όνομα",
|
||||
"memberPasswordLabel": "Κωδικός",
|
||||
"colorLabel": "Χρώμα",
|
||||
"profilePictureTitle": "Εικόνα προφίλ",
|
||||
"profilePictureLabel": "Μεταφόρτωση εικόνας",
|
||||
"profilePictureHint": "PNG, JPEG ή WebP. Οι μεγάλες εικόνες αλλάζουν μέγεθος πριν τη μεταφόρτωση.",
|
||||
"profilePictureRemove": "Αφαίρεση εικόνας",
|
||||
"profilePictureTypeError": "Χρησιμοποιήστε εικόνα PNG, JPEG ή WebP.",
|
||||
"profilePictureFileTooLarge": "Το αρχείο εικόνας είναι πολύ μεγάλο.",
|
||||
"profilePictureTooLarge": "Η εικόνα προφίλ παραμένει πολύ μεγάλη μετά την αλλαγή μεγέθους.",
|
||||
"profilePictureReadError": "Δεν ήταν δυνατή η ανάγνωση της επιλεγμένης εικόνας.",
|
||||
"profileSavedToast": "Το προφίλ ενημερώθηκε.",
|
||||
"editMemberLabel": "Επεξεργασία",
|
||||
"editMemberTitle": "Επεξεργασία μέλους οικογένειας",
|
||||
"saveMember": "Αποθήκευση μέλους",
|
||||
"memberUpdatedToast": "Το {{name}} ενημερώθηκε.",
|
||||
"familyRoleLabel": "Ρόλος στην οικογένεια",
|
||||
"familyRoleDad": "Μπαμπάς",
|
||||
"familyRoleMom": "Μαμά",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Display name",
|
||||
"memberPasswordLabel": "Password",
|
||||
"colorLabel": "Color",
|
||||
"profilePictureTitle": "Profile picture",
|
||||
"profilePictureLabel": "Upload picture",
|
||||
"profilePictureHint": "PNG, JPEG or WebP. Large images are resized before upload.",
|
||||
"profilePictureRemove": "Remove picture",
|
||||
"profilePictureTypeError": "Use a PNG, JPEG or WebP image.",
|
||||
"profilePictureFileTooLarge": "Image file is too large.",
|
||||
"profilePictureTooLarge": "Profile picture is still too large after resizing.",
|
||||
"profilePictureReadError": "Could not read the selected image.",
|
||||
"profileSavedToast": "Profile updated.",
|
||||
"editMemberLabel": "Edit",
|
||||
"editMemberTitle": "Edit family member",
|
||||
"saveMember": "Save member",
|
||||
"memberUpdatedToast": "{{name}} updated.",
|
||||
"familyRoleLabel": "Family role",
|
||||
"familyRoleDad": "Dad",
|
||||
"familyRoleMom": "Mom",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Nombre para mostrar",
|
||||
"memberPasswordLabel": "Contraseña",
|
||||
"colorLabel": "Color",
|
||||
"profilePictureTitle": "Foto de perfil",
|
||||
"profilePictureLabel": "Subir foto",
|
||||
"profilePictureHint": "PNG, JPEG o WebP. Las imágenes grandes se redimensionan antes de subirlas.",
|
||||
"profilePictureRemove": "Eliminar foto",
|
||||
"profilePictureTypeError": "Usa una imagen PNG, JPEG o WebP.",
|
||||
"profilePictureFileTooLarge": "El archivo de imagen es demasiado grande.",
|
||||
"profilePictureTooLarge": "La foto sigue siendo demasiado grande después de redimensionarla.",
|
||||
"profilePictureReadError": "No se pudo leer la imagen seleccionada.",
|
||||
"profileSavedToast": "Perfil actualizado.",
|
||||
"editMemberLabel": "Editar",
|
||||
"editMemberTitle": "Editar miembro de la familia",
|
||||
"saveMember": "Guardar miembro",
|
||||
"memberUpdatedToast": "{{name}} actualizado.",
|
||||
"familyRoleLabel": "Rol familiar",
|
||||
"familyRoleDad": "Papá",
|
||||
"familyRoleMom": "Mamá",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Nom affiché",
|
||||
"memberPasswordLabel": "Mot de passe",
|
||||
"colorLabel": "Couleur",
|
||||
"profilePictureTitle": "Photo de profil",
|
||||
"profilePictureLabel": "Importer une photo",
|
||||
"profilePictureHint": "PNG, JPEG ou WebP. Les grandes images sont redimensionnées avant l'envoi.",
|
||||
"profilePictureRemove": "Supprimer la photo",
|
||||
"profilePictureTypeError": "Utilisez une image PNG, JPEG ou WebP.",
|
||||
"profilePictureFileTooLarge": "Le fichier image est trop volumineux.",
|
||||
"profilePictureTooLarge": "La photo reste trop volumineuse après redimensionnement.",
|
||||
"profilePictureReadError": "Impossible de lire l'image sélectionnée.",
|
||||
"profileSavedToast": "Profil mis à jour.",
|
||||
"editMemberLabel": "Modifier",
|
||||
"editMemberTitle": "Modifier le membre de la famille",
|
||||
"saveMember": "Enregistrer le membre",
|
||||
"memberUpdatedToast": "{{name}} mis à jour.",
|
||||
"familyRoleLabel": "Rôle familial",
|
||||
"familyRoleDad": "Papa",
|
||||
"familyRoleMom": "Maman",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "प्रदर्शन नाम",
|
||||
"memberPasswordLabel": "पासवर्ड",
|
||||
"colorLabel": "रंग",
|
||||
"profilePictureTitle": "प्रोफ़ाइल चित्र",
|
||||
"profilePictureLabel": "चित्र अपलोड करें",
|
||||
"profilePictureHint": "PNG, JPEG या WebP. बड़े चित्र अपलोड से पहले छोटे किए जाते हैं।",
|
||||
"profilePictureRemove": "चित्र हटाएँ",
|
||||
"profilePictureTypeError": "PNG, JPEG या WebP चित्र इस्तेमाल करें।",
|
||||
"profilePictureFileTooLarge": "चित्र फ़ाइल बहुत बड़ी है।",
|
||||
"profilePictureTooLarge": "आकार बदलने के बाद भी प्रोफ़ाइल चित्र बहुत बड़ा है।",
|
||||
"profilePictureReadError": "चुना गया चित्र पढ़ा नहीं जा सका।",
|
||||
"profileSavedToast": "प्रोफ़ाइल अपडेट हुई।",
|
||||
"editMemberLabel": "संपादित करें",
|
||||
"editMemberTitle": "परिवार सदस्य संपादित करें",
|
||||
"saveMember": "सदस्य सहेजें",
|
||||
"memberUpdatedToast": "{{name}} अपडेट हुआ।",
|
||||
"familyRoleLabel": "परिवार भूमिका",
|
||||
"familyRoleDad": "पिता",
|
||||
"familyRoleMom": "माँ",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Nome visualizzato",
|
||||
"memberPasswordLabel": "Password",
|
||||
"colorLabel": "Colore",
|
||||
"profilePictureTitle": "Foto profilo",
|
||||
"profilePictureLabel": "Carica foto",
|
||||
"profilePictureHint": "PNG, JPEG o WebP. Le immagini grandi vengono ridimensionate prima del caricamento.",
|
||||
"profilePictureRemove": "Rimuovi foto",
|
||||
"profilePictureTypeError": "Usa un'immagine PNG, JPEG o WebP.",
|
||||
"profilePictureFileTooLarge": "Il file immagine è troppo grande.",
|
||||
"profilePictureTooLarge": "La foto è ancora troppo grande dopo il ridimensionamento.",
|
||||
"profilePictureReadError": "Impossibile leggere l'immagine selezionata.",
|
||||
"profileSavedToast": "Profilo aggiornato.",
|
||||
"editMemberLabel": "Modifica",
|
||||
"editMemberTitle": "Modifica membro della famiglia",
|
||||
"saveMember": "Salva membro",
|
||||
"memberUpdatedToast": "{{name}} aggiornato.",
|
||||
"familyRoleLabel": "Ruolo familiare",
|
||||
"familyRoleDad": "Papà",
|
||||
"familyRoleMom": "Mamma",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "表示名",
|
||||
"memberPasswordLabel": "パスワード",
|
||||
"colorLabel": "色",
|
||||
"profilePictureTitle": "プロフィール画像",
|
||||
"profilePictureLabel": "画像をアップロード",
|
||||
"profilePictureHint": "PNG、JPEG、WebP。大きな画像はアップロード前に縮小されます。",
|
||||
"profilePictureRemove": "画像を削除",
|
||||
"profilePictureTypeError": "PNG、JPEG、WebP画像を使用してください。",
|
||||
"profilePictureFileTooLarge": "画像ファイルが大きすぎます。",
|
||||
"profilePictureTooLarge": "縮小後もプロフィール画像が大きすぎます。",
|
||||
"profilePictureReadError": "選択した画像を読み込めませんでした。",
|
||||
"profileSavedToast": "プロフィールを更新しました。",
|
||||
"editMemberLabel": "編集",
|
||||
"editMemberTitle": "家族メンバーを編集",
|
||||
"saveMember": "メンバーを保存",
|
||||
"memberUpdatedToast": "{{name}} を更新しました。",
|
||||
"familyRoleLabel": "家族内の役割",
|
||||
"familyRoleDad": "父",
|
||||
"familyRoleMom": "母",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Nome de exibição",
|
||||
"memberPasswordLabel": "Senha",
|
||||
"colorLabel": "Cor",
|
||||
"profilePictureTitle": "Foto de perfil",
|
||||
"profilePictureLabel": "Enviar foto",
|
||||
"profilePictureHint": "PNG, JPEG ou WebP. Imagens grandes são redimensionadas antes do envio.",
|
||||
"profilePictureRemove": "Remover foto",
|
||||
"profilePictureTypeError": "Use uma imagem PNG, JPEG ou WebP.",
|
||||
"profilePictureFileTooLarge": "O arquivo de imagem é muito grande.",
|
||||
"profilePictureTooLarge": "A foto ainda está muito grande após o redimensionamento.",
|
||||
"profilePictureReadError": "Não foi possível ler a imagem selecionada.",
|
||||
"profileSavedToast": "Perfil atualizado.",
|
||||
"editMemberLabel": "Editar",
|
||||
"editMemberTitle": "Editar membro da família",
|
||||
"saveMember": "Salvar membro",
|
||||
"memberUpdatedToast": "{{name}} atualizado.",
|
||||
"familyRoleLabel": "Papel na família",
|
||||
"familyRoleDad": "Pai",
|
||||
"familyRoleMom": "Mãe",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Отображаемое имя",
|
||||
"memberPasswordLabel": "Пароль",
|
||||
"colorLabel": "Цвет",
|
||||
"profilePictureTitle": "Фото профиля",
|
||||
"profilePictureLabel": "Загрузить фото",
|
||||
"profilePictureHint": "PNG, JPEG или WebP. Большие изображения уменьшаются перед загрузкой.",
|
||||
"profilePictureRemove": "Удалить фото",
|
||||
"profilePictureTypeError": "Используйте изображение PNG, JPEG или WebP.",
|
||||
"profilePictureFileTooLarge": "Файл изображения слишком большой.",
|
||||
"profilePictureTooLarge": "Фото профиля всё ещё слишком большое после изменения размера.",
|
||||
"profilePictureReadError": "Не удалось прочитать выбранное изображение.",
|
||||
"profileSavedToast": "Профиль обновлён.",
|
||||
"editMemberLabel": "Изменить",
|
||||
"editMemberTitle": "Изменить члена семьи",
|
||||
"saveMember": "Сохранить участника",
|
||||
"memberUpdatedToast": "{{name}} обновлён.",
|
||||
"familyRoleLabel": "Роль в семье",
|
||||
"familyRoleDad": "Папа",
|
||||
"familyRoleMom": "Мама",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Visningsnamn",
|
||||
"memberPasswordLabel": "Lösenord",
|
||||
"colorLabel": "Färg",
|
||||
"profilePictureTitle": "Profilbild",
|
||||
"profilePictureLabel": "Ladda upp bild",
|
||||
"profilePictureHint": "PNG, JPEG eller WebP. Stora bilder storleksändras före uppladdning.",
|
||||
"profilePictureRemove": "Ta bort bild",
|
||||
"profilePictureTypeError": "Använd en PNG-, JPEG- eller WebP-bild.",
|
||||
"profilePictureFileTooLarge": "Bildfilen är för stor.",
|
||||
"profilePictureTooLarge": "Profilbilden är fortfarande för stor efter storleksändring.",
|
||||
"profilePictureReadError": "Kunde inte läsa den valda bilden.",
|
||||
"profileSavedToast": "Profilen uppdaterades.",
|
||||
"editMemberLabel": "Redigera",
|
||||
"editMemberTitle": "Redigera familjemedlem",
|
||||
"saveMember": "Spara medlem",
|
||||
"memberUpdatedToast": "{{name}} uppdaterad.",
|
||||
"familyRoleLabel": "Familjeroll",
|
||||
"familyRoleDad": "Pappa",
|
||||
"familyRoleMom": "Mamma",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Görünen ad",
|
||||
"memberPasswordLabel": "Şifre",
|
||||
"colorLabel": "Renk",
|
||||
"profilePictureTitle": "Profil resmi",
|
||||
"profilePictureLabel": "Resim yükle",
|
||||
"profilePictureHint": "PNG, JPEG veya WebP. Büyük resimler yüklemeden önce yeniden boyutlandırılır.",
|
||||
"profilePictureRemove": "Resmi kaldır",
|
||||
"profilePictureTypeError": "PNG, JPEG veya WebP resmi kullanın.",
|
||||
"profilePictureFileTooLarge": "Resim dosyası çok büyük.",
|
||||
"profilePictureTooLarge": "Profil resmi yeniden boyutlandırmadan sonra hâlâ çok büyük.",
|
||||
"profilePictureReadError": "Seçilen resim okunamadı.",
|
||||
"profileSavedToast": "Profil güncellendi.",
|
||||
"editMemberLabel": "Düzenle",
|
||||
"editMemberTitle": "Aile üyesini düzenle",
|
||||
"saveMember": "Üyeyi kaydet",
|
||||
"memberUpdatedToast": "{{name}} güncellendi.",
|
||||
"familyRoleLabel": "Aile rolü",
|
||||
"familyRoleDad": "Baba",
|
||||
"familyRoleMom": "Anne",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "Відображуване ім'я",
|
||||
"memberPasswordLabel": "Пароль",
|
||||
"colorLabel": "Колір",
|
||||
"profilePictureTitle": "Фото профілю",
|
||||
"profilePictureLabel": "Завантажити фото",
|
||||
"profilePictureHint": "PNG, JPEG або WebP. Великі зображення змінюються в розмірі перед завантаженням.",
|
||||
"profilePictureRemove": "Видалити фото",
|
||||
"profilePictureTypeError": "Використайте зображення PNG, JPEG або WebP.",
|
||||
"profilePictureFileTooLarge": "Файл зображення завеликий.",
|
||||
"profilePictureTooLarge": "Фото профілю все ще завелике після зміни розміру.",
|
||||
"profilePictureReadError": "Не вдалося прочитати вибране зображення.",
|
||||
"profileSavedToast": "Профіль оновлено.",
|
||||
"editMemberLabel": "Редагувати",
|
||||
"editMemberTitle": "Редагувати члена родини",
|
||||
"saveMember": "Зберегти члена",
|
||||
"memberUpdatedToast": "{{name}} оновлено.",
|
||||
"familyRoleLabel": "Роль у родині",
|
||||
"familyRoleDad": "Тато",
|
||||
"familyRoleMom": "Мама",
|
||||
|
||||
@@ -624,6 +624,19 @@
|
||||
"displayNameLabel": "显示名称",
|
||||
"memberPasswordLabel": "密码",
|
||||
"colorLabel": "颜色",
|
||||
"profilePictureTitle": "个人头像",
|
||||
"profilePictureLabel": "上传图片",
|
||||
"profilePictureHint": "PNG、JPEG 或 WebP。大图片会在上传前自动缩放。",
|
||||
"profilePictureRemove": "移除图片",
|
||||
"profilePictureTypeError": "请使用 PNG、JPEG 或 WebP 图片。",
|
||||
"profilePictureFileTooLarge": "图片文件太大。",
|
||||
"profilePictureTooLarge": "缩放后头像仍然太大。",
|
||||
"profilePictureReadError": "无法读取所选图片。",
|
||||
"profileSavedToast": "个人资料已更新。",
|
||||
"editMemberLabel": "编辑",
|
||||
"editMemberTitle": "编辑家庭成员",
|
||||
"saveMember": "保存成员",
|
||||
"memberUpdatedToast": "{{name}} 已更新。",
|
||||
"familyRoleLabel": "家庭角色",
|
||||
"familyRoleDad": "爸爸",
|
||||
"familyRoleMom": "妈妈",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -2262,12 +2262,19 @@
|
||||
font-size: var(--text-xs);
|
||||
border: 2px solid var(--color-surface);
|
||||
box-shadow: var(--shadow-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.family-widget-avatar + .family-widget-avatar {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.family-widget-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.budget-widget {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
color: var(--color-text-on-accent);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-avatar--sm {
|
||||
@@ -182,6 +183,38 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.settings-avatar--lg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.settings-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.settings-avatar-editor {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-avatar-editor__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.settings-avatar-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Formulare
|
||||
-------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user