From 5062e1e61fc00dfa2e59c274c5e8265292d535c4 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Tue, 28 Apr 2026 21:11:49 -0300 Subject: [PATCH] Improve account profile and sidebar details --- public/locales/ar.json | 3 +- public/locales/de.json | 3 +- public/locales/el.json | 3 +- public/locales/en.json | 3 +- public/locales/es.json | 3 +- public/locales/fr.json | 3 +- public/locales/hi.json | 3 +- public/locales/it.json | 3 +- public/locales/ja.json | 3 +- public/locales/pt.json | 3 +- public/locales/ru.json | 3 +- public/locales/sv.json | 3 +- public/locales/tr.json | 3 +- public/locales/uk.json | 3 +- public/locales/zh.json | 3 +- public/pages/settings.js | 126 +++++++++++++++++++++++++++---------- public/router.js | 43 ++++++++++++- public/styles/layout.css | 23 ++++++- public/styles/settings.css | 121 ++++++++++++++++++++++++++++++++--- public/sw.js | 8 +-- server/auth.js | 7 +++ 21 files changed, 308 insertions(+), 65 deletions(-) diff --git a/public/locales/ar.json b/public/locales/ar.json index bb882e4..bdb1594 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -28,7 +28,8 @@ "all": "الكل", "unknownError": "خطأ غير معروف", "confirm": "تأكيد", - "undo": "تراجع" + "undo": "تراجع", + "reset": "إعادة التعيين للأصل" }, "nav": { "dashboard": "لوحة التحكم", diff --git a/public/locales/de.json b/public/locales/de.json index 0bc4ce8..7836d42 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -28,7 +28,8 @@ "all": "Alle", "unknownError": "Unbekannter Fehler", "confirm": "Bestätigen", - "undo": "Rückgängig" + "undo": "Rückgängig", + "reset": "Auf Original zurücksetzen" }, "nav": { "dashboard": "Übersicht", diff --git a/public/locales/el.json b/public/locales/el.json index d5c4591..604f400 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -28,7 +28,8 @@ "all": "Όλα", "unknownError": "Άγνωστο σφάλμα", "confirm": "Επιβεβαίωση", - "undo": "Αναίρεση" + "undo": "Αναίρεση", + "reset": "Επαναφορά στο αρχικό" }, "nav": { "dashboard": "Επισκόπηση", diff --git a/public/locales/en.json b/public/locales/en.json index 1c11a2d..3bcf10b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -28,7 +28,8 @@ "all": "All", "unknownError": "Unknown error", "confirm": "Confirm", - "undo": "Undo" + "undo": "Undo", + "reset": "Reset to original" }, "nav": { "dashboard": "Overview", diff --git a/public/locales/es.json b/public/locales/es.json index f538132..7fba826 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -28,7 +28,8 @@ "all": "Todo", "unknownError": "Error desconocido", "confirm": "Confirmar", - "undo": "Deshacer" + "undo": "Deshacer", + "reset": "Restaurar original" }, "nav": { "dashboard": "Inicio", diff --git a/public/locales/fr.json b/public/locales/fr.json index be7ab85..b4d32aa 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -28,7 +28,8 @@ "all": "Tout", "unknownError": "Erreur inconnue", "confirm": "Confirmer", - "undo": "Annuler" + "undo": "Annuler", + "reset": "Réinitialiser" }, "nav": { "dashboard": "Accueil", diff --git a/public/locales/hi.json b/public/locales/hi.json index 7edb6a1..b83e25b 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -28,7 +28,8 @@ "all": "सभी", "unknownError": "अज्ञात त्रुटि", "confirm": "पुष्टि करें", - "undo": "पूर्ववत करें" + "undo": "पूर्ववत करें", + "reset": "मूल पर वापस जाएं" }, "nav": { "dashboard": "डैशबोर्ड", diff --git a/public/locales/it.json b/public/locales/it.json index 0876fb5..0b3f4e1 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -28,7 +28,8 @@ "all": "Tutto", "unknownError": "Errore sconosciuto", "confirm": "Conferma", - "undo": "Annulla" + "undo": "Annulla", + "reset": "Ripristina originale" }, "nav": { "dashboard": "Panoramica", diff --git a/public/locales/ja.json b/public/locales/ja.json index 6fbf989..0487682 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -28,7 +28,8 @@ "all": "すべて", "unknownError": "不明なエラー", "confirm": "確認", - "undo": "元に戻す" + "undo": "元に戻す", + "reset": "元に戻す" }, "nav": { "dashboard": "ダッシュボード", diff --git a/public/locales/pt.json b/public/locales/pt.json index 9d9d507..9a85cea 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -28,7 +28,8 @@ "all": "Todos", "unknownError": "Erro desconhecido", "confirm": "Confirmar", - "undo": "Desfazer" + "undo": "Desfazer", + "reset": "Restaurar original" }, "nav": { "dashboard": "Painel", diff --git a/public/locales/ru.json b/public/locales/ru.json index 8450c53..46bc0ac 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -28,7 +28,8 @@ "all": "Все", "unknownError": "Неизвестная ошибка", "confirm": "Подтвердить", - "undo": "Отменить" + "undo": "Отменить", + "reset": "Сбросить к исходному" }, "nav": { "dashboard": "Обзор", diff --git a/public/locales/sv.json b/public/locales/sv.json index d0d03ae..0e2eaf7 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -28,7 +28,8 @@ "all": "Alla", "unknownError": "Okänt fel", "confirm": "Bekräfta", - "undo": "Ångra" + "undo": "Ångra", + "reset": "Återställ till original" }, "nav": { "dashboard": "Översikt", diff --git a/public/locales/tr.json b/public/locales/tr.json index da24351..45a7a95 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -28,7 +28,8 @@ "all": "Tümü", "unknownError": "Bilinmeyen hata", "confirm": "Onayla", - "undo": "Geri al" + "undo": "Geri al", + "reset": "Orijinale sıfırla" }, "nav": { "dashboard": "Genel Bakış", diff --git a/public/locales/uk.json b/public/locales/uk.json index 89e77ab..e8b2822 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -28,7 +28,8 @@ "all": "Усі", "unknownError": "Невідома помилка", "confirm": "Підтвердити", - "undo": "Скасувати" + "undo": "Скасувати", + "reset": "Скинути до оригіналу" }, "nav": { "dashboard": "Огляд", diff --git a/public/locales/zh.json b/public/locales/zh.json index de18910..cb803b6 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -28,7 +28,8 @@ "all": "全部", "unknownError": "未知错误", "confirm": "确认", - "undo": "撤销" + "undo": "撤销", + "reset": "重置为原始" }, "nav": { "dashboard": "概览", diff --git a/public/pages/settings.js b/public/pages/settings.js index e3fcb02..a265fa2 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -101,14 +101,17 @@ function avatarHtml(user, className = 'settings-avatar') { function avatarEditorHtml(user, prefix) { return `
-
+
-
- - -

${t('settings.profilePictureHint')}

- + + +
+ +
`; @@ -121,6 +124,17 @@ function setAvatarPreview(container, selector, user) { preview.insertAdjacentHTML('beforeend', avatarHtml(user, 'settings-avatar settings-avatar--lg')); } +function bindAvatarPicker(container, prefix) { + const fileInput = container.querySelector(`#${prefix}-avatar-file`); + const pickers = [ + container.querySelector(`#${prefix}-avatar-preview`), + container.querySelector(`#${prefix}-avatar-edit`), + ]; + pickers.forEach((picker) => { + picker?.addEventListener('click', () => fileInput?.click()); + }); +} + function readImageAsDataUrl(file) { return new Promise((resolve, reject) => { if (!file) return resolve(undefined); @@ -168,6 +182,14 @@ function readImageAsDataUrl(file) { * @param {{ user: object }} context */ export async function render(container, { user }) { + try { + const me = await auth.me(); + if (me?.user && user) Object.assign(user, me.user); + else if (me?.user) user = me.user; + } catch { + // Non-critical: render with the user object provided by the router. + } + // URL-Parameter auswerten (z.B. nach OAuth-Callback) const params = new URLSearchParams(location.search); const syncOk = params.get('sync_ok'); @@ -525,18 +547,20 @@ export async function render(container, { user }) {
-
- - +
+
+ + +
+
+ + +
-
- - -
+
+ ${avatarEditorHtml(user, 'profile')} +
+
+
+ + +
+
+ + +
+
+
+
+
- - + + +

${t('settings.memberContactBirthdayHint')}

@@ -679,6 +724,7 @@ export async function render(container, { user }) { } bindEvents(container, user, users, categories, icsSubscriptions, apiTokens); + if (window.lucide) window.lucide.createIcons(); } // -------------------------------------------------------- @@ -791,6 +837,7 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok const profileState = { avatarData: user?.avatar_data ?? null }; const profileAvatarFile = container.querySelector('#profile-avatar-file'); + bindAvatarPicker(container, 'profile'); if (profileAvatarFile) { profileAvatarFile.addEventListener('change', async () => { const errorEl = container.querySelector('#profile-error'); @@ -828,13 +875,21 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok e.preventDefault(); const errorEl = container.querySelector('#profile-error'); const btn = profileForm.querySelector('[type=submit]'); + const birthDateRaw = container.querySelector('#profile-birth-date')?.value || ''; errorEl.hidden = true; + if (!isDateInputValid(birthDateRaw)) { + showError(errorEl, t('settings.memberBirthDateInvalid')); + return; + } 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, + phone: container.querySelector('#profile-phone')?.value.trim() || null, + email: container.querySelector('#profile-email')?.value.trim() || null, + birth_date: parseDateInput(birthDateRaw) || null, }); Object.assign(user, res.user); window.oikos?.showToast(t('settings.profileSavedToast'), 'success'); @@ -1120,18 +1175,24 @@ function openEditMemberModal(member, currentUser, users, container) { size: 'md', content: `
- ${avatarEditorHtml(member, 'edit-member')} -
- - -
-
- - -
-
- - +
+ ${avatarEditorHtml(member, 'edit-member')} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
@@ -1170,6 +1231,7 @@ function openEditMemberModal(member, currentUser, users, container) { const fileInput = panel.querySelector('#edit-member-avatar-file'); const errorEl = panel.querySelector('#edit-member-error'); bindSettingsDateInputs(panel); + bindAvatarPicker(panel, 'edit-member'); fileInput?.addEventListener('change', async () => { errorEl.hidden = true; try { diff --git a/public/router.js b/public/router.js index 924eec0..613824f 100644 --- a/public/router.js +++ b/public/router.js @@ -134,6 +134,7 @@ const PRIMARY_NAV = 4; const DEFAULT_APP_NAME = 'Oikos'; const APP_NAME_STORAGE_KEY = 'oikos-app-name'; +const APP_VERSION_STORAGE_KEY = 'oikos-app-version'; function getDirection(fromPath, toPath) { const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/'); @@ -146,6 +147,10 @@ function getAppName() { return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME; } +function getAppVersion() { + return localStorage.getItem(APP_VERSION_STORAGE_KEY) || ''; +} + function setAppName(name) { const next = String(name || '').trim(); if (next) { @@ -155,6 +160,15 @@ function setAppName(name) { } } +function setAppVersion(version) { + const next = String(version || '').trim(); + if (next) { + localStorage.setItem(APP_VERSION_STORAGE_KEY, next); + } else { + localStorage.removeItem(APP_VERSION_STORAGE_KEY); + } +} + function routeTitle(path) { const map = { '/': t('dashboard.title'), @@ -174,8 +188,14 @@ function routeTitle(path) { function updateBranding(path = currentPath) { const appName = getAppName(); - const sidebarLogoSpan = document.querySelector('.nav-sidebar__logo span'); - if (sidebarLogoSpan) sidebarLogoSpan.textContent = appName; + const sidebarLogoName = document.querySelector('.nav-sidebar__brand-name'); + if (sidebarLogoName) sidebarLogoName.textContent = appName; + const sidebarVersion = document.querySelector('.nav-sidebar__version'); + if (sidebarVersion) { + const version = getAppVersion(); + sidebarVersion.textContent = version ? t('login.version', { version }) : ''; + sidebarVersion.hidden = !version; + } const loginTitle = document.querySelector('.login-hero__title'); if (path === '/login' && loginTitle) loginTitle.textContent = appName; @@ -284,6 +304,14 @@ async function syncPreferencesOnce() { } catch { // Non-critical. The settings page can refresh this later. } + try { + const res = await api.get('/version'); + if (res?.version) setAppVersion(res.version); + if (res?.app_name) setAppName(res.app_name); + updateBranding(); + } catch { + // Non-critical. The login page and settings page can refresh branding later. + } } /** @@ -427,9 +455,18 @@ function renderAppShell(container) { logomark.appendChild(logoSvg); sidebarLogo.appendChild(logomark); + const sidebarBrandText = document.createElement('div'); + sidebarBrandText.className = 'nav-sidebar__brand-text'; const sidebarLogoSpan = document.createElement('span'); + sidebarLogoSpan.className = 'nav-sidebar__brand-name'; sidebarLogoSpan.textContent = getAppName(); - sidebarLogo.appendChild(sidebarLogoSpan); + const sidebarVersion = document.createElement('small'); + sidebarVersion.className = 'nav-sidebar__version'; + const cachedVersion = getAppVersion(); + sidebarVersion.textContent = cachedVersion ? t('login.version', { version: cachedVersion }) : ''; + sidebarVersion.hidden = !cachedVersion; + sidebarBrandText.append(sidebarLogoSpan, sidebarVersion); + sidebarLogo.appendChild(sidebarBrandText); const sidebarItems = document.createElement('div'); sidebarItems.className = 'nav-sidebar__items'; sidebarItems.setAttribute('role', 'list'); diff --git a/public/styles/layout.css b/public/styles/layout.css index 4913ced..f58a965 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -560,7 +560,7 @@ } /* Logo-Text verstecken im collapsed-Modus */ - .nav-sidebar__logo > span { + .nav-sidebar__brand-text { display: none; } @@ -665,12 +665,29 @@ flex-shrink: 0; } - .nav-sidebar__logo > span { - display: inline-block; + .nav-sidebar__brand-text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.1; + } + + .nav-sidebar__brand-name { font-size: var(--text-lg); font-weight: var(--font-weight-bold); letter-spacing: -0.3px; color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .nav-sidebar__version { + margin-top: var(--space-0h); + font-size: 10px; + font-weight: var(--font-weight-medium); + letter-spacing: 0.04em; + color: var(--color-text-tertiary); } .nav-sidebar__items { diff --git a/public/styles/settings.css b/public/styles/settings.css index 7433143..6b0dfc7 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -10,11 +10,11 @@ .settings-page { --module-accent: var(--module-settings); } /* -------------------------------------------------------- - Seiten-Layout - nutzt layout-center (max 720px) + Seiten-Layout -------------------------------------------------------- */ .settings-page { - max-width: var(--content-max-width-narrow); + max-width: var(--content-max-width); margin: 0 auto; } @@ -48,6 +48,7 @@ .settings-tabs { display: flex; + flex-wrap: nowrap; gap: 0; overflow-x: auto; scrollbar-width: none; @@ -67,7 +68,7 @@ .settings-tab-btn { flex-shrink: 0; - padding: var(--space-3) var(--space-4); + padding: var(--space-3) clamp(var(--space-2), 1.3vw, var(--space-4)); border: none; border-bottom: 2px solid transparent; background: transparent; @@ -196,23 +197,127 @@ } .settings-avatar-editor { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); +} + +.settings-avatar-button { + display: block; + padding: 0; + border: none; + border-radius: var(--radius-full); + background: transparent; + cursor: pointer; + transition: transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.settings-avatar-button:hover, +.settings-avatar-button:focus-visible { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + outline: none; +} + +.settings-avatar-actions { + display: flex; + justify-content: center; + gap: var(--space-1); +} + +.settings-avatar-action { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-surface); + color: var(--color-text-secondary); + cursor: pointer; + transition: color var(--transition-fast), border-color var(--transition-fast), background-color var(--transition-fast); +} + +.settings-avatar-action:hover, +.settings-avatar-action:focus-visible { + color: var(--color-accent); + border-color: var(--color-accent); + background: var(--color-accent-light); +} + +.settings-avatar-action--danger:hover, +.settings-avatar-action--danger:focus-visible { + color: var(--color-danger); + border-color: var(--color-danger); + background: var(--color-danger-light); +} + +.settings-avatar-action i, +.settings-avatar-action svg { + width: 14px; + height: 14px; +} + +.settings-profile-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); +.settings-profile-editor__fields { min-width: 0; } +.settings-name-color-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-3); + align-items: end; +} + +.settings-name-color-row__name { + min-width: 0; +} + +.settings-color-field { + align-items: center; +} + +.settings-color-button { + width: 44px; + height: 44px; + padding: 2px; + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-surface); + cursor: pointer; +} + +.settings-color-button::-webkit-color-swatch-wrapper { + padding: 0; +} + +.settings-color-button::-webkit-color-swatch { + border: none; + border-radius: var(--radius-full); +} + +.settings-color-button::-moz-color-swatch { + border: none; + border-radius: var(--radius-full); +} + @media (max-width: 520px) { - .settings-avatar-editor { + .settings-profile-editor { grid-template-columns: 1fr; } + + .settings-avatar-editor { + align-items: flex-start; + } } /* -------------------------------------------------------- diff --git a/public/sw.js b/public/sw.js index 714e38b..4b17d5c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,10 +13,10 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v62'; -const PAGES_CACHE = 'oikos-pages-v57'; -const LOCALES_CACHE = 'oikos-locales-v8'; -const ASSETS_CACHE = 'oikos-assets-v57'; +const SHELL_CACHE = 'oikos-shell-v65'; +const PAGES_CACHE = 'oikos-pages-v60'; +const LOCALES_CACHE = 'oikos-locales-v9'; +const ASSETS_CACHE = 'oikos-assets-v60'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; diff --git a/server/auth.js b/server/auth.js index b047eae..6d3c276 100644 --- a/server/auth.js +++ b/server/auth.js @@ -828,6 +828,7 @@ router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => { const avatarData = req.body.avatar_data !== undefined ? normalizeAvatarData(req.body.avatar_data) : existing.avatar_data; + const memberFields = validateMemberProfileFields(req.body); if (!displayName) return res.status(400).json({ error: 'Display name is required.', code: 400 }); if (displayName.length > 128) { @@ -836,6 +837,9 @@ router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => { if (avatarData?.error) { return res.status(400).json({ error: avatarData.error, code: 400 }); } + if (memberFields.errors.length) { + return res.status(400).json({ error: memberFields.errors.join(' '), code: 400 }); + } db.transaction(() => { db.get().prepare(` @@ -845,6 +849,9 @@ router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => { `).run(displayName, avatarColor || '#007AFF', avatarData ?? null, req.authUserId); syncFamilyMemberArtifacts(db.get(), req.authUserId, { displayName, + phone: memberFields.values.phone, + email: memberFields.values.email, + birthDate: memberFields.values.birth_date, avatarData: avatarData ?? null, actorUserId: req.authUserId, });