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
- ? `

`
- : `
${UI.icon('dog')}
`}
-
+ ? `
+

+
`
+ : `
${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