UI-Verbesserungen: Favicon, Tab-Titel, Kalender umbenennen, Avatar-Crop, Farbpalette

- SVG-Favicon hinzugefügt
- Dynamischer Tab-Titel (z.B. "Calendarr - März 2026")
- Kalender per Doppelklick umbenennen (Backend + Frontend)
- Avatar-Anzeige im Topbar gefixt (onerror Fallback, robustes Laden)
- Avatar-Upload mit Cropper.js Bildausschnitt-Wahl
- Avatar-Limit auf 5 MB erhöht, Thumbnail auf 512px
- Farbpalette statt nativem Color-Picker für Kalenderfarben
This commit is contained in:
2026-03-26 15:14:34 +01:00
parent 77d6e20f86
commit 1bbabd6c4d
7 changed files with 256 additions and 43 deletions

View File

@@ -27,6 +27,7 @@ class AccountCreate(BaseModel):
class CalendarUpdate(BaseModel):
enabled: Optional[bool] = None
color: Optional[str] = None
name: Optional[str] = None
class EventCreate(BaseModel):
@@ -221,6 +222,8 @@ def update_calendar(
calendar.enabled = data.enabled
if data.color is not None:
calendar.color = data.color
if data.name is not None:
calendar.name = data.name
db.commit()
return {"ok": True}

View File

@@ -19,7 +19,7 @@ router = APIRouter()
AVATAR_DIR = DATA_DIR / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2 MB
MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
@@ -78,12 +78,12 @@ async def upload_avatar(
data = await file.read()
if len(data) > MAX_AVATAR_SIZE:
raise HTTPException(400, "Datei zu groß (max. 2 MB)")
raise HTTPException(400, "Datei zu groß (max. 5 MB)")
# Resize to 256x256
img = Image.open(io.BytesIO(data))
img = img.convert("RGB")
img.thumbnail((256, 256))
img.thumbnail((512, 512))
filename = f"user_{current_user.id}.jpg"
path = AVATAR_DIR / filename

View File

@@ -307,18 +307,33 @@ a { color: var(--primary); text-decoration: none; }
margin-right: 12px;
}
.cal-item:hover { background: var(--bg-hover); }
.cal-item-dot-wrapper { position: relative; flex-shrink: 0; }
.cal-item-dot {
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
}
.cal-item-dot:hover { outline: 2px solid var(--text-2); outline-offset: 1px; }
.cal-color-input {
position: absolute; top: 0; left: 0;
width: 14px; height: 14px; opacity: 0;
pointer-events: none;
.cal-color-picker {
position: fixed; z-index: 500;
display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
padding: 10px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg);
}
.cal-cp-swatch {
width: 28px; height: 28px; border-radius: 50%;
cursor: pointer; transition: transform .1s, box-shadow .1s;
}
.cal-cp-swatch:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px var(--bg-surface), 0 0 0 4px currentColor;
}
.cal-item input[type=checkbox] { accent-color: var(--primary); width: 14px; height: 14px; }
.cal-item-name { font-size: 13px; flex: 1; color: var(--text-1); }
.cal-item-name { font-size: 13px; flex: 1; color: var(--text-1); cursor: default; }
.cal-rename-input {
font-size: 13px; flex: 1; color: var(--text-1);
background: var(--bg-app); border: 1px solid var(--primary);
border-radius: var(--radius-sm); padding: 2px 6px;
outline: none;
}
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
.cal-item-remove { opacity: 0; }
.cal-item:hover .cal-item-remove { opacity: 1; }
@@ -619,6 +634,10 @@ a { color: var(--primary); text-decoration: none; }
.profile-cal-name { font-size: 13px; color: var(--text-1); }
.profile-cal-account { font-size: 11px; color: var(--text-3); }
/* ── Avatar Crop ───────────────────────────────────────── */
.crop-container { max-height: 400px; overflow: hidden; background: #000; }
.crop-container img { display: block; max-width: 100%; }
/* ── Toast ──────────────────────────────────────────────── */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);

13
frontend/favicon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<rect x="4" y="8" width="40" height="36" rx="4" fill="#4285f4"/>
<rect x="4" y="8" width="40" height="10" rx="4" fill="#2b6de0"/>
<rect x="4" y="14" width="40" height="4" fill="#2b6de0"/>
<circle cx="16" cy="12" r="2" fill="#fff"/>
<circle cx="32" cy="12" r="2" fill="#fff"/>
<rect x="10" y="24" width="6" height="5" rx="1" fill="rgba(255,255,255,.85)"/>
<rect x="21" y="24" width="6" height="5" rx="1" fill="rgba(255,255,255,.85)"/>
<rect x="32" y="24" width="6" height="5" rx="1" fill="rgba(255,255,255,.85)"/>
<rect x="10" y="33" width="6" height="5" rx="1" fill="rgba(255,255,255,.85)"/>
<rect x="21" y="33" width="6" height="5" rx="1" fill="rgba(255,255,255,.85)"/>
<rect x="32" y="33" width="6" height="5" rx="1" fill="rgba(255,255,255,.4)"/>
</svg>

After

Width:  |  Height:  |  Size: 839 B

View File

@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendarr</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
<link rel="stylesheet" href="/static/css/app.css" />
</head>
<body>
@@ -481,9 +483,29 @@
</div>
</div>
<!-- Avatar Crop Modal -->
<div id="modal-crop" class="modal-overlay hidden" style="z-index:1000">
<div class="modal-card" style="max-width:480px">
<div class="modal-header">
<h3>Bildausschnitt wählen</h3>
<button class="icon-btn modal-close" data-modal="modal-crop">&times;</button>
</div>
<div class="modal-body" style="padding:0">
<div class="crop-container">
<img id="crop-image" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" data-modal="modal-crop">Abbrechen</button>
<button class="btn btn-primary" id="crop-save">Zuschneiden & Hochladen</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -86,8 +86,10 @@ async function launchApp() {
// Load avatar image if available
try {
const me = await api.get('/auth/me');
// Store extended user info
localStorage.setItem('user', JSON.stringify({ ...user, ...me }));
if (me.has_avatar) {
avatar.innerHTML = `<img src="/api/profile/avatar?t=${Date.now()}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
loadAvatarImage(avatar, user.username);
}
} catch (_) {}
@@ -158,5 +160,20 @@ function bindLoginForm() {
});
}
// ── Avatar Helper ────────────────────────────────────────
function loadAvatarImage(avatarEl, username) {
const img = new Image();
img.onload = () => {
avatarEl.textContent = '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%';
avatarEl.appendChild(img);
};
img.onerror = () => {
// Fallback to letter
avatarEl.textContent = (username || '?')[0].toUpperCase();
};
img.src = `/api/profile/avatar?t=${Date.now()}`;
}
// ── Start ─────────────────────────────────────────────────
boot();

View File

@@ -147,6 +147,7 @@ function updateTitle() {
title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
document.getElementById('view-title').textContent = title;
document.title = `Calendarr - ${title}`;
}
function updateViewButtons() {
@@ -232,10 +233,7 @@ function renderCalendarList() {
acc.calendars.map(cal =>
`<div class="cal-item" data-cal-id="${cal.id}">
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" />
<div class="cal-item-dot-wrapper">
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" title="Farbe ändern"></div>
<input type="color" class="cal-color-input" data-cal-id="${cal.id}" value="${cal.color || '#4285f4'}" />
</div>
<span class="cal-item-name">${escHtml(cal.name)}</span>
<button class="icon-btn mini-btn cal-item-remove" data-acc-id="${acc.id}" title="Konto entfernen">
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
@@ -263,23 +261,42 @@ function renderCalendarList() {
container.querySelectorAll('.cal-item-dot').forEach(dot => {
dot.addEventListener('click', e => {
e.stopPropagation();
const colorInput = dot.parentElement.querySelector('.cal-color-input');
colorInput.click();
const calId = parseInt(dot.dataset.calId);
openCalColorPicker(dot, calId);
});
});
container.querySelectorAll('.cal-color-input').forEach(input => {
input.addEventListener('change', async e => {
const calId = parseInt(e.target.dataset.calId);
const color = e.target.value;
await api.put(`/caldav/calendars/${calId}`, { color });
container.querySelectorAll('.cal-item-name').forEach(nameEl => {
nameEl.addEventListener('dblclick', e => {
e.stopPropagation();
const calId = parseInt(nameEl.closest('.cal-item').dataset.calId);
const currentName = nameEl.textContent;
const input = document.createElement('input');
input.type = 'text';
input.value = currentName;
input.className = 'cal-rename-input';
nameEl.replaceWith(input);
input.focus();
input.select();
const save = async () => {
const newName = input.value.trim();
if (newName && newName !== currentName) {
await api.put(`/caldav/calendars/${calId}`, { name: newName });
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) cal.color = color;
if (cal.id === calId) cal.name = newName;
}
}
}
renderCalendarList();
fetchAndRender();
};
input.addEventListener('keydown', e => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') renderCalendarList();
});
input.addEventListener('blur', save);
});
});
@@ -784,24 +801,11 @@ function bindProfileModal() {
document.getElementById('profile-avatar-input').click();
};
document.getElementById('profile-avatar-input').onchange = async (e) => {
document.getElementById('profile-avatar-input').onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
try {
await api.upload('/profile/avatar', form);
showToast('Profilbild hochgeladen');
// Update display
const img = document.getElementById('profile-avatar-img');
img.src = `/api/profile/avatar?t=${Date.now()}`;
img.classList.remove('hidden');
document.getElementById('profile-avatar-letter').classList.add('hidden');
document.getElementById('profile-avatar-remove').classList.remove('hidden');
// Update topbar avatar
updateTopbarAvatar(true);
} catch (err) { showToast(err.message, true); }
e.target.value = '';
openCropModal(file);
};
document.getElementById('profile-avatar-remove').onclick = async () => {
@@ -880,13 +884,148 @@ function bindProfileModal() {
function updateTopbarAvatar(hasAvatar) {
const avatar = document.getElementById('user-avatar');
if (hasAvatar) {
avatar.innerHTML = `<img src="/api/profile/avatar?t=${Date.now()}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
const img = new Image();
img.onload = () => { avatar.textContent = ''; img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%'; avatar.appendChild(img); };
img.onerror = () => { const u = JSON.parse(localStorage.getItem('user')||'{}'); avatar.textContent = (u.username||'?')[0].toUpperCase(); };
img.src = `/api/profile/avatar?t=${Date.now()}`;
} else {
const user = JSON.parse(localStorage.getItem('user') || '{}');
avatar.textContent = (user.username || '?')[0].toUpperCase();
}
}
// ── Calendar Color Picker ─────────────────────────────────
const CAL_COLORS = [
'#4285f4', '#7986cb', '#8e24aa', '#e67c73',
'#f4511e', '#f6bf26', '#33b679', '#0b8043',
'#039be5', '#616161', '#3f51b5', '#d50000',
'#e4c441', '#009688', '#795548', '#ef6c00',
];
let activeColorPicker = null;
function openCalColorPicker(anchor, calId) {
closeCalColorPicker();
const rect = anchor.getBoundingClientRect();
const picker = document.createElement('div');
picker.className = 'cal-color-picker';
picker.innerHTML = CAL_COLORS.map(c =>
`<div class="cal-cp-swatch" data-color="${c}" style="background:${c}" title="${c}"></div>`
).join('');
picker.style.top = (rect.bottom + 6) + 'px';
picker.style.left = rect.left + 'px';
// Ensure picker stays in viewport
document.body.appendChild(picker);
const pRect = picker.getBoundingClientRect();
if (pRect.right > window.innerWidth - 8) {
picker.style.left = (window.innerWidth - pRect.width - 8) + 'px';
}
picker.querySelectorAll('.cal-cp-swatch').forEach(sw => {
sw.addEventListener('click', async (e) => {
e.stopPropagation();
const color = sw.dataset.color;
await api.put(`/caldav/calendars/${calId}`, { color });
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) cal.color = color;
}
}
closeCalColorPicker();
renderCalendarList();
fetchAndRender();
});
});
activeColorPicker = picker;
// Close on outside click (next tick to avoid immediate close)
setTimeout(() => {
document.addEventListener('click', closeCalColorPickerOutside);
}, 0);
}
function closeCalColorPicker() {
if (activeColorPicker) {
activeColorPicker.remove();
activeColorPicker = null;
document.removeEventListener('click', closeCalColorPickerOutside);
}
}
function closeCalColorPickerOutside(e) {
if (activeColorPicker && !activeColorPicker.contains(e.target)) {
closeCalColorPicker();
}
}
// ── Avatar Crop ──────────────────────────────────────────
let activeCropper = null;
function openCropModal(file) {
const reader = new FileReader();
reader.onload = (e) => {
const cropImg = document.getElementById('crop-image');
cropImg.src = e.target.result;
openModal('modal-crop');
// Destroy previous cropper if any
if (activeCropper) { activeCropper.destroy(); activeCropper = null; }
// Wait for image to load then init cropper
cropImg.onload = () => {
activeCropper = new Cropper(cropImg, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 1,
cropBoxResizable: true,
cropBoxMovable: true,
background: false,
});
};
};
reader.readAsDataURL(file);
}
document.getElementById('crop-save').onclick = async () => {
if (!activeCropper) return;
const canvas = activeCropper.getCroppedCanvas({
width: 512,
height: 512,
imageSmoothingQuality: 'high',
});
canvas.toBlob(async (blob) => {
const form = new FormData();
form.append('file', blob, 'avatar.jpg');
try {
await api.upload('/profile/avatar', form);
showToast('Profilbild hochgeladen');
// Update profile modal avatar
const img = document.getElementById('profile-avatar-img');
img.src = `/api/profile/avatar?t=${Date.now()}`;
img.classList.remove('hidden');
document.getElementById('profile-avatar-letter').classList.add('hidden');
document.getElementById('profile-avatar-remove').classList.remove('hidden');
// Update topbar avatar
updateTopbarAvatar(true);
closeModal('modal-crop');
} catch (err) { showToast(err.message, true); }
}, 'image/jpeg', 0.9);
};
// Clean up cropper when modal closes
document.getElementById('modal-crop').addEventListener('click', (e) => {
if (e.target.matches('[data-modal="modal-crop"]') || e.target === document.getElementById('modal-crop')) {
if (activeCropper) { activeCropper.destroy(); activeCropper = null; }
}
});
// ── Modal helpers ─────────────────────────────────────────
function openModal(id) {
document.getElementById(id).classList.remove('hidden');