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

@@ -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>
<div class="cal-item-dot" style="background:${cal.color}" data-cal-id="${cal.id}" title="Farbe ändern"></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 });
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
if (cal.id === calId) cal.color = 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.name = newName;
}
}
}
}
renderCalendarList();
fetchAndRender();
renderCalendarList();
};
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');