diff --git a/backend/database.py b/backend/database.py index d1ede93..ae19c35 100644 --- a/backend/database.py +++ b/backend/database.py @@ -460,6 +460,10 @@ def _migrate(conn_factory): ("direct_messages", "read_at", "TEXT"), # Chat: Online-Indikator ("users", "last_seen", "TEXT"), + # Foto-Editor: Zoom + Position + ("dogs", "foto_zoom", "REAL NOT NULL DEFAULT 1.0"), + ("dogs", "foto_offset_x", "REAL NOT NULL DEFAULT 0.0"), + ("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index d5ffd29..a58c757 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -155,6 +155,43 @@ async def upload_photo( return {"foto_url": foto_url} +class PhotoPosition(BaseModel): + zoom: float = 1.0 + offset_x: float = 0.0 + offset_y: float = 0.0 + + +@router.patch("/{dog_id}/photo-position") +async def update_photo_position(dog_id: int, pos: PhotoPosition, user=Depends(get_current_user)): + with db() as conn: + updated = conn.execute( + "UPDATE dogs SET foto_zoom=?, foto_offset_x=?, foto_offset_y=? WHERE id=? AND user_id=?", + (pos.zoom, pos.offset_x, pos.offset_y, dog_id, user["id"]) + ).rowcount + if not updated: + raise HTTPException(404, "Hund nicht gefunden.") + return {"ok": True} + + +@router.delete("/{dog_id}/photo", status_code=204) +async def delete_photo(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT foto_url FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not row: + raise HTTPException(404, "Hund nicht gefunden.") + if row["foto_url"]: + path = os.path.join(MEDIA_DIR, row["foto_url"].lstrip("/media/")) + if os.path.exists(path): + os.remove(path) + with db() as conn: + conn.execute( + "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?", + (dog_id,) + ) + + # Öffentliches Profil (für NFC-Tag, kein Login nötig) @router.get("/public/{dog_id}") async def public_dog_profile(dog_id: int): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index ba5dfd0..6d8c002 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -4567,3 +4567,55 @@ textarea.form-control { right: 1px; border: 2px solid var(--c-surface); } + +/* --- Hundeprofil Avatar --- */ +.dp-avatar-ring { + width: 120px; height: 120px; + border-radius: 50%; + border: 3px solid var(--c-primary); + overflow: hidden; + display: flex; align-items: center; justify-content: center; +} +.dp-avatar-img { + width: 100%; height: 100%; + object-fit: cover; + transform-origin: center; + pointer-events: none; + user-select: none; +} +.dp-avatar-empty { + background: var(--c-surface-2); + border-color: var(--c-border); + font-size: 3.5rem; +} +.dp-avatar-edit-btn { + position: absolute; bottom: 4px; right: 4px; + background: var(--c-primary); color: #fff; + border: none; border-radius: 50%; + width: 30px; height: 30px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; +} + +/* --- Foto-Editor Modal --- */ +.photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; } +.photo-editor-preview { + width: 200px; height: 200px; + border-radius: 50%; + border: 3px solid var(--c-primary); + overflow: hidden; + cursor: grab; + display: flex; align-items: center; justify-content: center; + background: var(--c-surface-2); +} +.photo-editor-preview:active { cursor: grabbing; } +.photo-editor-preview img { + width: 100%; height: 100%; + object-fit: cover; + transform-origin: center; + pointer-events: all; + user-select: none; + -webkit-user-drag: none; +} +.photo-editor-empty { font-size: 5rem; color: var(--c-text-secondary); } +.photo-editor-controls { width: 100%; } diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 6c0abc5..4e0ca58 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -93,6 +93,10 @@ const API = (() => { update(id, data) { return patch(`/dogs/${id}`, data); }, delete(id) { return del(`/dogs/${id}`); }, uploadPhoto(id, formData) { return upload(`/dogs/${id}/photo`, formData); }, + updatePhotoPosition(id, zoom, offsetX, offsetY) { + return patch(`/dogs/${id}/photo-position`, { zoom, offset_x: offsetX, offset_y: offsetY }); + }, + deletePhoto(id) { return del(`/dogs/${id}/photo`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 3123faf..8bd04bb 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -79,22 +79,14 @@ window.Page_dog_profile = (() => {
${dog.foto_url - ? `${_esc(dog.name)}` - : `
${UI.icon('dog')}
`} - + ? `
+ ${_esc(dog.name)} +
` + : `
${UI.icon('dog')}
`} +
@@ -206,24 +198,9 @@ window.Page_dog_profile = (() => { `; - // Foto hochladen - document.getElementById('dp-photo-input')?.addEventListener('change', async e => { - const file = e.target.files[0]; - if (!file) return; - try { - const fd = new FormData(); - fd.append('file', file); - const result = await API.dogs.uploadPhoto(dog.id, fd); - dog.foto_url = result.foto_url; - _appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url }; - _appState.dogs = _appState.dogs.map(d => - d.id === dog.id ? _appState.activeDog : d - ); - UI.toast.success('Foto gespeichert.'); - _renderProfile(_appState.activeDog); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Hochladen.'); - } + // Foto-Editor öffnen + document.getElementById('dp-photo-edit-btn')?.addEventListener('click', () => { + _showPhotoEditor(dog); }); // NFC-Link kopieren document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => { @@ -293,6 +270,131 @@ window.Page_dog_profile = (() => { }); } + // ---------------------------------------------------------- + // FOTO-EDITOR + // ---------------------------------------------------------- + function _showPhotoEditor(dog) { + const hasPhoto = !!dog.foto_url; + const zoom = dog.foto_zoom || 1.0; + const ox = dog.foto_offset_x || 0.0; + const oy = dog.foto_offset_y || 0.0; + + const body = ` +
+
+ ${hasPhoto + ? `` + : `
${UI.icon('dog')}
`} +
+ ${hasPhoto ? ` +
+ + +
+ ` : ''} + +
+ `; + + const footer = ` + ${hasPhoto ? `` : ''} + + ${hasPhoto ? `` : ''} + `; + + UI.modal.open({ title: 'Foto bearbeiten', body, footer }); + + // State für Drag + let _zoom = zoom, _ox = ox, _oy = oy; + const img = document.getElementById('pe-img'); + const zoomSlider = document.getElementById('pe-zoom'); + + function _applyTransform() { + if (img) img.style.transform = `scale(${_zoom}) translate(${_ox}px,${_oy}px)`; + } + + // Zoom-Slider + zoomSlider?.addEventListener('input', e => { + _zoom = parseFloat(e.target.value); + _applyTransform(); + }); + + // Drag-to-pan + if (img) { + let _dragging = false, _startX = 0, _startY = 0, _baseX = _ox, _baseY = _oy; + + img.addEventListener('pointerdown', e => { + _dragging = true; _startX = e.clientX; _startY = e.clientY; + _baseX = _ox; _baseY = _oy; + img.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + img.addEventListener('pointermove', e => { + if (!_dragging) return; + _ox = _baseX + (e.clientX - _startX) / _zoom; + _oy = _baseY + (e.clientY - _startY) / _zoom; + _applyTransform(); + }); + img.addEventListener('pointerup', () => { _dragging = false; }); + } + + // Speichern + document.getElementById('pe-save-btn')?.addEventListener('click', async () => { + try { + await API.dogs.updatePhotoPosition(dog.id, _zoom, _ox, _oy); + _appState.activeDog = { ..._appState.activeDog, foto_zoom: _zoom, foto_offset_x: _ox, foto_offset_y: _oy }; + _appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d); + UI.modal.close(); + UI.toast.success('Position gespeichert.'); + _renderProfile(_appState.activeDog); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Speichern.'); + } + }); + + // Löschen + document.getElementById('pe-delete-btn')?.addEventListener('click', async () => { + if (!confirm('Foto wirklich löschen?')) return; + try { + await API.dogs.deletePhoto(dog.id); + _appState.activeDog = { ..._appState.activeDog, foto_url: null, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 }; + _appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d); + UI.modal.close(); + UI.toast.success('Foto gelöscht.'); + _renderProfile(_appState.activeDog); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Löschen.'); + } + }); + + // Neues Foto hochladen + document.getElementById('pe-file-input')?.addEventListener('change', async e => { + const file = e.target.files[0]; + if (!file) return; + try { + const fd = new FormData(); + fd.append('file', file); + const result = await API.dogs.uploadPhoto(dog.id, fd); + // Position zurücksetzen + await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0); + _appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 }; + _appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d); + UI.modal.close(); + UI.toast.success('Foto hochgeladen.'); + _renderProfile(_appState.activeDog); + // Editor neu öffnen damit User positionieren kann + _showPhotoEditor(_appState.activeDog); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Hochladen.'); + } + }); + } + // ---------------------------------------------------------- // AUSWEIS // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index d900237..9c199a2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v147'; +const CACHE_VERSION = 'by-v148'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten