Feature: Foto-Editor im Hundeprofil — Zoom, Drag-to-pan, Löschen

This commit is contained in:
rene 2026-04-17 23:00:21 +02:00
parent cb8ac8cffd
commit 913cebcba1
6 changed files with 234 additions and 35 deletions

View file

@ -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:

View file

@ -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):

View file

@ -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%; }

View file

@ -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`); },
};
// ----------------------------------------------------------

View file

@ -79,22 +79,14 @@ window.Page_dog_profile = (() => {
<!-- Profilfoto mit Upload-Button -->
<div style="position:relative;display:inline-block;margin-bottom:var(--space-4)">
${dog.foto_url
? `<img src="${dog.foto_url}" alt="${_esc(dog.name)}"
style="width:120px;height:120px;border-radius:50%;object-fit:cover;
border:3px solid var(--c-primary)">`
: `<div style="width:120px;height:120px;border-radius:50%;
background:var(--c-surface-2);display:flex;
align-items:center;justify-content:center;
font-size:3.5rem;border:3px solid var(--c-border)">${UI.icon('dog')}</div>`}
<label style="position:absolute;bottom:4px;right:4px;
background:var(--c-primary);color:#fff;border-radius:50%;
width:30px;height:30px;display:flex;align-items:center;
justify-content:center;cursor:pointer;font-size:14px"
title="Foto ändern">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<input type="file" id="dp-photo-input" accept="image/*"
style="display:none">
</label>
? `<div class="dp-avatar-ring">
<img src="${dog.foto_url}" alt="${_esc(dog.name)}" class="dp-avatar-img"
style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}px,${dog.foto_offset_y||0}px)">
</div>`
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
<button class="dp-avatar-edit-btn" id="dp-photo-edit-btn" title="Foto bearbeiten">
${UI.icon('camera')}
</button>
</div>
<!-- Name + Rasse -->
@ -206,24 +198,9 @@ window.Page_dog_profile = (() => {
</div>
`;
// 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 = `
<div class="photo-editor">
<div class="photo-editor-preview" id="pe-preview">
${hasPhoto
? `<img src="${UI.escape(dog.foto_url)}" id="pe-img" draggable="false"
style="transform:scale(${zoom}) translate(${ox}px,${oy}px)">`
: `<div class="photo-editor-empty">${UI.icon('dog')}</div>`}
</div>
${hasPhoto ? `
<div class="photo-editor-controls">
<label class="form-label">Zoom</label>
<input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}"
style="width:100%">
</div>
` : ''}
<label class="btn btn-secondary" style="cursor:pointer">
${UI.icon('upload-simple')} Neues Foto wählen
<input type="file" id="pe-file-input" accept="image/*" style="display:none">
</label>
</div>
`;
const footer = `
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
<button class="btn btn-ghost" onclick="UI.modal.close()">Abbrechen</button>
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn">Speichern</button>` : ''}
`;
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
// ----------------------------------------------------------

View file

@ -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