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):
|
class CalendarUpdate(BaseModel):
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
@@ -221,6 +222,8 @@ def update_calendar(
|
|||||||
calendar.enabled = data.enabled
|
calendar.enabled = data.enabled
|
||||||
if data.color is not None:
|
if data.color is not None:
|
||||||
calendar.color = data.color
|
calendar.color = data.color
|
||||||
|
if data.name is not None:
|
||||||
|
calendar.name = data.name
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
AVATAR_DIR = DATA_DIR / "avatars"
|
AVATAR_DIR = DATA_DIR / "avatars"
|
||||||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
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"}
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ async def upload_avatar(
|
|||||||
|
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
if len(data) > MAX_AVATAR_SIZE:
|
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
|
# Resize to 256x256
|
||||||
img = Image.open(io.BytesIO(data))
|
img = Image.open(io.BytesIO(data))
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
img.thumbnail((256, 256))
|
img.thumbnail((512, 512))
|
||||||
|
|
||||||
filename = f"user_{current_user.id}.jpg"
|
filename = f"user_{current_user.id}.jpg"
|
||||||
path = AVATAR_DIR / filename
|
path = AVATAR_DIR / filename
|
||||||
|
|||||||
@@ -307,18 +307,33 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
.cal-item:hover { background: var(--bg-hover); }
|
.cal-item:hover { background: var(--bg-hover); }
|
||||||
.cal-item-dot-wrapper { position: relative; flex-shrink: 0; }
|
|
||||||
.cal-item-dot {
|
.cal-item-dot {
|
||||||
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
|
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-item-dot:hover { outline: 2px solid var(--text-2); outline-offset: 1px; }
|
||||||
.cal-color-input {
|
.cal-color-picker {
|
||||||
position: absolute; top: 0; left: 0;
|
position: fixed; z-index: 500;
|
||||||
width: 14px; height: 14px; opacity: 0;
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
|
||||||
pointer-events: none;
|
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 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-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
|
||||||
.cal-item-remove { opacity: 0; }
|
.cal-item-remove { opacity: 0; }
|
||||||
.cal-item:hover .cal-item-remove { opacity: 1; }
|
.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-name { font-size: 13px; color: var(--text-1); }
|
||||||
.profile-cal-account { font-size: 11px; color: var(--text-3); }
|
.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 ──────────────────────────────────────────────── */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Calendarr</title>
|
<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" />
|
<link rel="stylesheet" href="/static/css/app.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -481,9 +483,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Toast -->
|
||||||
<div id="toast" class="toast hidden"></div>
|
<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>
|
<script type="module" src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ async function launchApp() {
|
|||||||
// Load avatar image if available
|
// Load avatar image if available
|
||||||
try {
|
try {
|
||||||
const me = await api.get('/auth/me');
|
const me = await api.get('/auth/me');
|
||||||
|
// Store extended user info
|
||||||
|
localStorage.setItem('user', JSON.stringify({ ...user, ...me }));
|
||||||
if (me.has_avatar) {
|
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 (_) {}
|
} 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 ─────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function updateTitle() {
|
|||||||
title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
title = `Ab ${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
document.getElementById('view-title').textContent = title;
|
document.getElementById('view-title').textContent = title;
|
||||||
|
document.title = `Calendarr - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateViewButtons() {
|
function updateViewButtons() {
|
||||||
@@ -232,10 +233,7 @@ function renderCalendarList() {
|
|||||||
acc.calendars.map(cal =>
|
acc.calendars.map(cal =>
|
||||||
`<div class="cal-item" data-cal-id="${cal.id}">
|
`<div class="cal-item" data-cal-id="${cal.id}">
|
||||||
<input type="checkbox" ${cal.enabled ? 'checked' : ''} 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>
|
<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>
|
<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">
|
<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>
|
<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 => {
|
container.querySelectorAll('.cal-item-dot').forEach(dot => {
|
||||||
dot.addEventListener('click', e => {
|
dot.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const colorInput = dot.parentElement.querySelector('.cal-color-input');
|
const calId = parseInt(dot.dataset.calId);
|
||||||
colorInput.click();
|
openCalColorPicker(dot, calId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
container.querySelectorAll('.cal-color-input').forEach(input => {
|
container.querySelectorAll('.cal-item-name').forEach(nameEl => {
|
||||||
input.addEventListener('change', async e => {
|
nameEl.addEventListener('dblclick', e => {
|
||||||
const calId = parseInt(e.target.dataset.calId);
|
e.stopPropagation();
|
||||||
const color = e.target.value;
|
const calId = parseInt(nameEl.closest('.cal-item').dataset.calId);
|
||||||
await api.put(`/caldav/calendars/${calId}`, { color });
|
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 acc of state.accounts) {
|
||||||
for (const cal of acc.calendars) {
|
for (const cal of acc.calendars) {
|
||||||
if (cal.id === calId) cal.color = color;
|
if (cal.id === calId) cal.name = newName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderCalendarList();
|
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').click();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('profile-avatar-input').onchange = async (e) => {
|
document.getElementById('profile-avatar-input').onchange = (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
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 = '';
|
e.target.value = '';
|
||||||
|
openCropModal(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('profile-avatar-remove').onclick = async () => {
|
document.getElementById('profile-avatar-remove').onclick = async () => {
|
||||||
@@ -880,13 +884,148 @@ function bindProfileModal() {
|
|||||||
function updateTopbarAvatar(hasAvatar) {
|
function updateTopbarAvatar(hasAvatar) {
|
||||||
const avatar = document.getElementById('user-avatar');
|
const avatar = document.getElementById('user-avatar');
|
||||||
if (hasAvatar) {
|
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 {
|
} else {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
avatar.textContent = (user.username || '?')[0].toUpperCase();
|
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 ─────────────────────────────────────────
|
// ── Modal helpers ─────────────────────────────────────────
|
||||||
function openModal(id) {
|
function openModal(id) {
|
||||||
document.getElementById(id).classList.remove('hidden');
|
document.getElementById(id).classList.remove('hidden');
|
||||||
|
|||||||
Reference in New Issue
Block a user