From 4ab07ddcc3ceea282bb2da150c3c95055cd56363 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 14 Jul 2026 21:25:01 +0200 Subject: [PATCH] Web polish from testing feedback + admin role toggle - style the settings-table dropdowns for the dark theme (were white) - share-icon picker on one even row (no lopsided wrap) - more breathing room between colour hex and swatch - user management: promote/demote admin (new PUT /users/{id}/admin, guarded against self-change and removing the last admin) - calendar management table: clip/ellipsis cells so narrow columns no longer overlap (full-width detail rows still wrap) - centralise default colours: utils.applyTheme now derives every fallback from settings-sync.DEFAULT_COLORS (single place for coders to edit) Co-Authored-By: Claude Opus 4.8 --- backend/routers/users_router.py | 26 ++++++++++++++++++++++++++ frontend/css/app.css | 22 +++++++++++++++++++--- frontend/js/calendar.js | 26 +++++++++++++++++++------- frontend/js/i18n.js | 2 ++ frontend/js/utils.js | 24 +++++++++++++----------- frontend/sw.js | 2 +- 6 files changed, 80 insertions(+), 22 deletions(-) diff --git a/backend/routers/users_router.py b/backend/routers/users_router.py index e81d803..ca24a97 100644 --- a/backend/routers/users_router.py +++ b/backend/routers/users_router.py @@ -23,6 +23,10 @@ class ChangePasswordRequest(BaseModel): password: str +class SetAdminRequest(BaseModel): + is_admin: bool + + def _user_dict(u: models.User) -> dict: return { "id": u.id, @@ -102,6 +106,28 @@ def delete_user( return {"ok": True} +@router.put("/{user_id}/admin") +def set_admin( + user_id: int, + req: SetAdminRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_admin), +): + if user_id == current_user.id: + raise HTTPException(400, "Cannot change your own admin status") + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(404, "User not found") + # Never leave the instance without an admin. + if user.is_admin and not req.is_admin: + admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712 + if admin_count <= 1: + raise HTTPException(400, "At least one admin must remain") + user.is_admin = req.is_admin + db.commit() + return {"ok": True} + + @router.put("/{user_id}/password") def change_password( user_id: int, diff --git a/frontend/css/app.css b/frontend/css/app.css index 7750937..c11836d 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1377,8 +1377,19 @@ a { color: var(--primary); text-decoration: none; } .settings-row-name { font-size: 14px; color: var(--text-1); } .settings-row-value { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } .settings-row-value select { + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 12px; + color: var(--text-1); + outline: none; + cursor: pointer; + color-scheme: dark; min-width: 130px; max-width: 100%; + transition: border-color var(--transition); } +.settings-row-value select:hover:not(:focus) { border-color: var(--text-3); } +.settings-row-value select:focus { border-color: var(--primary); } /* Sync toggle switch — used per row and (larger) globally */ .sync-toggle { @@ -1400,15 +1411,16 @@ a { color: var(--primary); text-decoration: none; } .settings-row .sync-toggle { justify-self: center; } /* Per-row colour control: swatch + hex + reset, right-aligned */ -.settings-color-ctl { display: flex; align-items: center; gap: 6px; } +.settings-color-ctl { display: flex; align-items: center; gap: 12px; } .settings-color-ctl .ev-color-hex { width: 92px; } +.settings-color-ctl .ev-color-preview { margin-left: 2px; } .settings-color-ctl .ev-color-reset { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; color: var(--text-3); } -/* Compact inline share-icon picker inside a value cell */ -.settings-icon-ctl { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; max-width: 220px; } +/* Inline share-icon picker inside a value cell — one even row */ +.settings-icon-ctl { display: flex; flex-wrap: nowrap; gap: 6px; justify-content: flex-end; } @media (max-width: 640px) { .settings-row { grid-template-columns: 34px 1fr; grid-auto-rows: auto; } @@ -1541,6 +1553,10 @@ a { color: var(--primary); text-decoration: none; } border-bottom: 1px solid var(--border-light); } .cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; } +/* Fixed layout + narrow drags must clip, not overlap the next column (Excel-like). */ +.cal-manage-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Full-width detail/section rows (account headers, CalDAV boxes, empty state) wrap normally. */ +.cal-manage-table td[colspan] { overflow: visible; white-space: normal; } .cal-manage-table tbody tr:last-child td { border-bottom: none; } /* Drag-resizable columns: a grab handle on each header's right edge. */ .cal-manage-table th { position: relative; } diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 8eab897..cc5d9ba 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -4248,20 +4248,32 @@ async function loadUsers() { try { const users = await api.get('/users/'); const list = document.getElementById('users-list'); - list.innerHTML = users.map(u => - `
+ const selfId = JSON.parse(localStorage.getItem('user') || '{}').id; + list.innerHTML = users.map(u => { + const isSelf = u.id === selfId; + return `
${escHtml(u.username)}
${u.email ? `
${escHtml(u.email)}
` : ''}
${u.is_admin ? 'Admin' : ''} - ${u.id !== JSON.parse(localStorage.getItem('user')||'{}').id - ? `` - : ''} + ${isSelf ? '' : + ``} + ${isSelf ? '' : + ``}
-
` - ).join(''); +
`; + }).join(''); + + list.querySelectorAll('[data-admin-user]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await api.put(`/users/${btn.dataset.adminUser}/admin`, { is_admin: btn.dataset.adminNext === '1' }); + loadUsers(); + } catch (e) { showToast(e.message, true); } + }); + }); list.querySelectorAll('[data-del-user]').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 5800f97..b818c19 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -212,6 +212,7 @@ const translations = { // User management users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator', + users_make_admin: 'Zu Admin machen', users_revoke_admin: 'Admin entziehen', // Profile profile_title: 'Profil', profile_upload: 'Bild hochladen', @@ -564,6 +565,7 @@ const translations = { // User management users_add: 'Add user', users_is_admin: 'Administrator', + users_make_admin: 'Make admin', users_revoke_admin: 'Revoke admin', // Profile profile_title: 'Profile', profile_upload: 'Upload image', diff --git a/frontend/js/utils.js b/frontend/js/utils.js index 00c53c6..f245bfd 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -1,3 +1,5 @@ +import { DEFAULT_COLORS } from './settings-sync.js'; + export function isToday(d) { const now = new Date(); return d.getFullYear() === now.getFullYear() && @@ -96,18 +98,18 @@ const LINE_CONTRAST = { 4: { border: '#5a5a78', light: '#484860' }, }; -// Defaults wenn kein Custom-Override gesetzt ist. -// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet. -export const DEFAULT_TEXT_COLOR = '#FFFFFF'; -export const DEFAULT_LINE_COLOR = '#3A3A52'; -export const DEFAULT_BG_COLOR = '#000000'; +// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js. +// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig. +export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color; +export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color; +export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color; export function applyTheme(settings) { const root = document.documentElement; - root.style.setProperty('--primary', settings.primary_color || '#4285f4'); - root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15)); - root.style.setProperty('--accent', settings.accent_color || '#ea4335'); - root.style.setProperty('--today-color', settings.today_color || '#4285f4'); + root.style.setProperty('--primary', settings.primary_color || DEFAULT_COLORS.primary_color); + root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || DEFAULT_COLORS.primary_color, 0.15)); + root.style.setProperty('--accent', settings.accent_color || DEFAULT_COLORS.accent_color); + root.style.setProperty('--today-color', settings.today_color || DEFAULT_COLORS.today_color); // Effektive Farben bestimmen (Override > Default). let textColor = settings.text_color || DEFAULT_TEXT_COLOR; @@ -140,8 +142,8 @@ export function applyTheme(settings) { const hh = settings.hour_height || 44; root.style.setProperty('--hour-h', hh + 'px'); - root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0'); - root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0'); + root.style.setProperty('--month-divider-color', settings.month_divider_color || DEFAULT_COLORS.month_divider_color); + root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_color); } function luminance(hex) { diff --git a/frontend/sw.js b/frontend/sw.js index aecdda6..9ce3525 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -7,7 +7,7 @@ // the entry HTML / version files). New releases take effect on the next // reload, no manual SW unregister required. -const CACHE_VERSION = 'calendarr-v26'; +const CACHE_VERSION = 'calendarr-v27'; const OFFLINE_SHELL = ['/', '/index.html']; self.addEventListener('install', event => {