Feature: Foto-Editor im Hundeprofil — Zoom, Drag-to-pan, Löschen
This commit is contained in:
parent
cb8ac8cffd
commit
913cebcba1
6 changed files with 234 additions and 35 deletions
|
|
@ -460,6 +460,10 @@ def _migrate(conn_factory):
|
||||||
("direct_messages", "read_at", "TEXT"),
|
("direct_messages", "read_at", "TEXT"),
|
||||||
# Chat: Online-Indikator
|
# Chat: Online-Indikator
|
||||||
("users", "last_seen", "TEXT"),
|
("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:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,43 @@ async def upload_photo(
|
||||||
return {"foto_url": foto_url}
|
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)
|
# Öffentliches Profil (für NFC-Tag, kein Login nötig)
|
||||||
@router.get("/public/{dog_id}")
|
@router.get("/public/{dog_id}")
|
||||||
async def public_dog_profile(dog_id: int):
|
async def public_dog_profile(dog_id: int):
|
||||||
|
|
|
||||||
|
|
@ -4567,3 +4567,55 @@ textarea.form-control {
|
||||||
right: 1px;
|
right: 1px;
|
||||||
border: 2px solid var(--c-surface);
|
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%; }
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,10 @@ const API = (() => {
|
||||||
update(id, data) { return patch(`/dogs/${id}`, data); },
|
update(id, data) { return patch(`/dogs/${id}`, data); },
|
||||||
delete(id) { return del(`/dogs/${id}`); },
|
delete(id) { return del(`/dogs/${id}`); },
|
||||||
uploadPhoto(id, formData) { return upload(`/dogs/${id}/photo`, formData); },
|
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`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -79,22 +79,14 @@ window.Page_dog_profile = (() => {
|
||||||
<!-- Profilfoto mit Upload-Button -->
|
<!-- Profilfoto mit Upload-Button -->
|
||||||
<div style="position:relative;display:inline-block;margin-bottom:var(--space-4)">
|
<div style="position:relative;display:inline-block;margin-bottom:var(--space-4)">
|
||||||
${dog.foto_url
|
${dog.foto_url
|
||||||
? `<img src="${dog.foto_url}" alt="${_esc(dog.name)}"
|
? `<div class="dp-avatar-ring">
|
||||||
style="width:120px;height:120px;border-radius:50%;object-fit:cover;
|
<img src="${dog.foto_url}" alt="${_esc(dog.name)}" class="dp-avatar-img"
|
||||||
border:3px solid var(--c-primary)">`
|
style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}px,${dog.foto_offset_y||0}px)">
|
||||||
: `<div style="width:120px;height:120px;border-radius:50%;
|
</div>`
|
||||||
background:var(--c-surface-2);display:flex;
|
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
|
||||||
align-items:center;justify-content:center;
|
<button class="dp-avatar-edit-btn" id="dp-photo-edit-btn" title="Foto bearbeiten">
|
||||||
font-size:3.5rem;border:3px solid var(--c-border)">${UI.icon('dog')}</div>`}
|
${UI.icon('camera')}
|
||||||
<label style="position:absolute;bottom:4px;right:4px;
|
</button>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- Name + Rasse -->
|
<!-- Name + Rasse -->
|
||||||
|
|
@ -206,24 +198,9 @@ window.Page_dog_profile = (() => {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Foto hochladen
|
// Foto-Editor öffnen
|
||||||
document.getElementById('dp-photo-input')?.addEventListener('change', async e => {
|
document.getElementById('dp-photo-edit-btn')?.addEventListener('click', () => {
|
||||||
const file = e.target.files[0];
|
_showPhotoEditor(dog);
|
||||||
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.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// NFC-Link kopieren
|
// NFC-Link kopieren
|
||||||
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
|
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
|
// AUSWEIS
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v147';
|
const CACHE_VERSION = 'by-v148';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue