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 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,10 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetAdminRequest(BaseModel):
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
def _user_dict(u: models.User) -> dict:
|
def _user_dict(u: models.User) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
@@ -102,6 +106,28 @@ def delete_user(
|
|||||||
return {"ok": True}
|
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")
|
@router.put("/{user_id}/password")
|
||||||
def change_password(
|
def change_password(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1377,8 +1377,19 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.settings-row-name { font-size: 14px; color: var(--text-1); }
|
.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 { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||||
.settings-row-value select {
|
.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%;
|
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 switch — used per row and (larger) globally */
|
||||||
.sync-toggle {
|
.sync-toggle {
|
||||||
@@ -1400,15 +1411,16 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.settings-row .sync-toggle { justify-self: center; }
|
.settings-row .sync-toggle { justify-self: center; }
|
||||||
|
|
||||||
/* Per-row colour control: swatch + hex + reset, right-aligned */
|
/* 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-hex { width: 92px; }
|
||||||
|
.settings-color-ctl .ev-color-preview { margin-left: 2px; }
|
||||||
.settings-color-ctl .ev-color-reset {
|
.settings-color-ctl .ev-color-reset {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
width: 28px; height: 28px; padding: 0; color: var(--text-3);
|
width: 28px; height: 28px; padding: 0; color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact inline share-icon picker inside a value cell */
|
/* Inline share-icon picker inside a value cell — one even row */
|
||||||
.settings-icon-ctl { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; max-width: 220px; }
|
.settings-icon-ctl { display: flex; flex-wrap: nowrap; gap: 6px; justify-content: flex-end; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-row { grid-template-columns: 34px 1fr; grid-auto-rows: auto; }
|
.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);
|
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; }
|
.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; }
|
.cal-manage-table tbody tr:last-child td { border-bottom: none; }
|
||||||
/* Drag-resizable columns: a grab handle on each header's right edge. */
|
/* Drag-resizable columns: a grab handle on each header's right edge. */
|
||||||
.cal-manage-table th { position: relative; }
|
.cal-manage-table th { position: relative; }
|
||||||
|
|||||||
@@ -4248,20 +4248,32 @@ async function loadUsers() {
|
|||||||
try {
|
try {
|
||||||
const users = await api.get('/users/');
|
const users = await api.get('/users/');
|
||||||
const list = document.getElementById('users-list');
|
const list = document.getElementById('users-list');
|
||||||
list.innerHTML = users.map(u =>
|
const selfId = JSON.parse(localStorage.getItem('user') || '{}').id;
|
||||||
`<div class="users-list-item">
|
list.innerHTML = users.map(u => {
|
||||||
|
const isSelf = u.id === selfId;
|
||||||
|
return `<div class="users-list-item">
|
||||||
<div>
|
<div>
|
||||||
<div class="uname">${escHtml(u.username)}</div>
|
<div class="uname">${escHtml(u.username)}</div>
|
||||||
${u.email ? `<div class="uemail">${escHtml(u.email)}</div>` : ''}
|
${u.email ? `<div class="uemail">${escHtml(u.email)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
${u.is_admin ? '<span class="ubadge">Admin</span>' : ''}
|
${u.is_admin ? '<span class="ubadge">Admin</span>' : ''}
|
||||||
${u.id !== JSON.parse(localStorage.getItem('user')||'{}').id
|
${isSelf ? '' :
|
||||||
? `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" data-del-user="${u.id}">Löschen</button>`
|
`<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" data-admin-user="${u.id}" data-admin-next="${u.is_admin ? '0' : '1'}">${u.is_admin ? t('users_revoke_admin') : t('users_make_admin')}</button>`}
|
||||||
: ''}
|
${isSelf ? '' :
|
||||||
|
`<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" data-del-user="${u.id}">${t('delete')}</button>`}
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`;
|
||||||
).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 => {
|
list.querySelectorAll('[data-del-user]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const translations = {
|
|||||||
|
|
||||||
// User management
|
// User management
|
||||||
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
|
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
|
||||||
|
users_make_admin: 'Zu Admin machen', users_revoke_admin: 'Admin entziehen',
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
profile_title: 'Profil', profile_upload: 'Bild hochladen',
|
profile_title: 'Profil', profile_upload: 'Bild hochladen',
|
||||||
@@ -564,6 +565,7 @@ const translations = {
|
|||||||
|
|
||||||
// User management
|
// User management
|
||||||
users_add: 'Add user', users_is_admin: 'Administrator',
|
users_add: 'Add user', users_is_admin: 'Administrator',
|
||||||
|
users_make_admin: 'Make admin', users_revoke_admin: 'Revoke admin',
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
profile_title: 'Profile', profile_upload: 'Upload image',
|
profile_title: 'Profile', profile_upload: 'Upload image',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DEFAULT_COLORS } from './settings-sync.js';
|
||||||
|
|
||||||
export function isToday(d) {
|
export function isToday(d) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return d.getFullYear() === now.getFullYear() &&
|
return d.getFullYear() === now.getFullYear() &&
|
||||||
@@ -96,18 +98,18 @@ const LINE_CONTRAST = {
|
|||||||
4: { border: '#5a5a78', light: '#484860' },
|
4: { border: '#5a5a78', light: '#484860' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Defaults wenn kein Custom-Override gesetzt ist.
|
// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js.
|
||||||
// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
|
// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig.
|
||||||
export const DEFAULT_TEXT_COLOR = '#FFFFFF';
|
export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color;
|
||||||
export const DEFAULT_LINE_COLOR = '#3A3A52';
|
export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color;
|
||||||
export const DEFAULT_BG_COLOR = '#000000';
|
export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
||||||
|
|
||||||
export function applyTheme(settings) {
|
export function applyTheme(settings) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
root.style.setProperty('--primary', settings.primary_color || DEFAULT_COLORS.primary_color);
|
||||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
|
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || DEFAULT_COLORS.primary_color, 0.15));
|
||||||
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
root.style.setProperty('--accent', settings.accent_color || DEFAULT_COLORS.accent_color);
|
||||||
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
root.style.setProperty('--today-color', settings.today_color || DEFAULT_COLORS.today_color);
|
||||||
|
|
||||||
// Effektive Farben bestimmen (Override > Default).
|
// Effektive Farben bestimmen (Override > Default).
|
||||||
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
||||||
@@ -140,8 +142,8 @@ export function applyTheme(settings) {
|
|||||||
const hh = settings.hour_height || 44;
|
const hh = settings.hour_height || 44;
|
||||||
root.style.setProperty('--hour-h', hh + 'px');
|
root.style.setProperty('--hour-h', hh + 'px');
|
||||||
|
|
||||||
root.style.setProperty('--month-divider-color', settings.month_divider_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 || '#7090c0');
|
root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
function luminance(hex) {
|
function luminance(hex) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// the entry HTML / version files). New releases take effect on the next
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
// reload, no manual SW unregister required.
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v26';
|
const CACHE_VERSION = 'calendarr-v27';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|||||||
Reference in New Issue
Block a user