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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
13
frontend/favicon.svg
Normal 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 |
@@ -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">×</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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user