Feature: Tagebuch Multi-Medien (beliebig viele Fotos/Videos pro Eintrag)

- Backend: neue Tabelle diary_media (Migration), upload_media schreibt
  jetzt in diary_media statt media_url; neuer DELETE-Endpoint
  /diary/{id}/media/{media_id}; alle GET-Endpoints liefern media_items[].
- Frontend: Multi-Upload-Grid im Formular mit Vorschau und X-Button
  zum Entfernen vor dem Speichern; bestehende Medien im Edit-Modus
  einzeln löschbar; Detail-Ansicht zeigt horizontale Scroll-Galerie
  bei mehreren Medien; Karten-Badge zeigt Anzahl bei > 1 Medium.
- Rückwärtskompatibilität: Einträge mit media_url werden weiterhin
  korrekt angezeigt.
- SW by-v211, APP_VER 181
This commit is contained in:
rene 2026-04-18 18:45:48 +02:00
parent 6581a9a88c
commit 63ab092f5e
7 changed files with 367 additions and 165 deletions

View file

@ -466,6 +466,11 @@ def _migrate(conn_factory):
("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"), ("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"),
# Tagebuch: Ortsname (POI/Adresse) # Tagebuch: Ortsname (POI/Adresse)
("diary", "location_name", "TEXT"), ("diary", "location_name", "TEXT"),
# Bewertungen: walks + sitters brauchen bewertung + anz_bewertungen
("walks", "bewertung", "REAL DEFAULT 0"),
("walks", "anz_bewertungen", "INTEGER DEFAULT 0"),
("sitters", "bewertung", "REAL DEFAULT 0"),
("sitters", "anz_bewertungen", "INTEGER DEFAULT 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:
@ -739,6 +744,36 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type); CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type);
""") """)
# Ratings — einheitliches Bewertungssystem
conn.executescript("""
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
stars INTEGER NOT NULL,
kommentar TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, target_type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_ratings_target ON ratings(target_type, target_id);
""")
logger.info("Migration: ratings Tabelle bereit.")
# Tagebuch: mehrere Mediendateien pro Eintrag
conn.executescript("""
CREATE TABLE IF NOT EXISTS diary_media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE,
url TEXT NOT NULL,
media_type TEXT NOT NULL DEFAULT 'image',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_diary_media_entry ON diary_media(diary_id, sort_order);
""")
logger.info("Migration: diary_media Tabelle bereit.")
# Walk-Einladungen (RSVP) # Walk-Einladungen (RSVP)
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS walk_invitations ( CREATE TABLE IF NOT EXISTS walk_invitations (

View file

@ -86,10 +86,30 @@ def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
) )
def _entry_dict(row, dog_ids_map: dict) -> dict: def _fetch_media_items(conn, entry_ids: list[int]) -> dict:
"""Gibt {entry_id: [{url, media_type, sort_order, id}, ...]} zurück."""
if not entry_ids:
return {}
ph = ",".join("?" * len(entry_ids))
rows = conn.execute(
f"SELECT id, diary_id, url, media_type, sort_order FROM diary_media "
f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order",
entry_ids
).fetchall()
result = {}
for r in rows:
result.setdefault(r["diary_id"], []).append({
"id": r["id"], "url": r["url"],
"media_type": r["media_type"], "sort_order": r["sort_order"]
})
return result
def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict:
e = dict(row) e = dict(row)
e["tags"] = json.loads(e["tags"]) if e["tags"] else [] e["tags"] = json.loads(e["tags"]) if e["tags"] else []
e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]]) e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
e["media_items"] = (media_map or {}).get(e["id"], [])
return e return e
@ -122,9 +142,10 @@ async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
(dog_id, dog_id, limit, offset) (dog_id, dog_id, limit, offset)
).fetchall() ).fetchall()
ids = [r["id"] for r in rows] ids = [r["id"] for r in rows]
dogs_map = _fetch_dog_ids(conn, ids) dogs_map = _fetch_dog_ids(conn, ids)
media_map = _fetch_media_items(conn, ids)
return [_entry_dict(r, dogs_map) for r in rows] return [_entry_dict(r, dogs_map, media_map) for r in rows]
@router.post("/{dog_id}/diary", status_code=201) @router.post("/{dog_id}/diary", status_code=201)
@ -158,9 +179,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
(dog_id,) (dog_id,)
).fetchone() ).fetchone()
_set_dog_ids(conn, entry["id"], all_dogs) _set_dog_ids(conn, entry["id"], all_dogs)
dogs_map = _fetch_dog_ids(conn, [entry["id"]]) dogs_map = _fetch_dog_ids(conn, [entry["id"]])
media_map = _fetch_media_items(conn, [entry["id"]])
return _entry_dict(entry, dogs_map) return _entry_dict(entry, dogs_map, media_map)
def _haversine_km(lat1, lon1, lat2, lon2) -> float: def _haversine_km(lat1, lon1, lat2, lon2) -> float:
@ -302,9 +324,10 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
).fetchone() ).fetchone()
if not row: if not row:
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")
dogs_map = _fetch_dog_ids(conn, [entry_id]) dogs_map = _fetch_dog_ids(conn, [entry_id])
media_map = _fetch_media_items(conn, [entry_id])
return _entry_dict(row, dogs_map) return _entry_dict(row, dogs_map, media_map)
@router.patch("/{dog_id}/diary/{entry_id}") @router.patch("/{dog_id}/diary/{entry_id}")
@ -345,10 +368,11 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn) all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn)
_set_dog_ids(conn, entry_id, all_dogs) _set_dog_ids(conn, entry_id, all_dogs)
row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
dogs_map = _fetch_dog_ids(conn, [entry_id]) dogs_map = _fetch_dog_ids(conn, [entry_id])
media_map = _fetch_media_items(conn, [entry_id])
return _entry_dict(row, dogs_map) return _entry_dict(row, dogs_map, media_map)
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204) @router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
@ -360,6 +384,16 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user
) )
def _guess_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower()
if ct.startswith("video/"):
return "video"
ext = os.path.splitext(filename or "")[1].lower()
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
return "video"
return "image"
@router.post("/{dog_id}/diary/{entry_id}/media") @router.post("/{dog_id}/diary/{entry_id}/media")
async def upload_media(dog_id: int, entry_id: int, async def upload_media(dog_id: int, entry_id: int,
file: UploadFile = File(...), file: UploadFile = File(...),
@ -386,29 +420,58 @@ async def upload_media(dog_id: int, entry_id: int,
".mp4",".mov",".webm",".m4v"}: ".mp4",".mov",".webm",".m4v"}:
raise HTTPException(415, "Nur Bilder und Videos erlaubt.") raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg" ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "diary", filename) path = os.path.join(MEDIA_DIR, "diary", filename)
media_type = _guess_media_type(ct, file.filename or "")
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(await file.read()) f.write(await file.read())
# Altes Medium von Disk löschen wenn vorhanden media_url = f"/media/diary/{filename}"
with db() as conn:
old = conn.execute("SELECT media_url FROM diary WHERE id=?", (entry_id,)).fetchone()
if old and old["media_url"]:
old_path = os.path.join(MEDIA_DIR, old["media_url"].lstrip("/media/"))
try: os.remove(old_path)
except OSError: pass
media_url = f"/media/diary/{filename}"
conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
return {"media_url": media_url} with db() as conn:
# sort_order = nächste freie Position
max_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?",
(entry_id,)
).fetchone()[0]
conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order) VALUES (?,?,?,?)",
(entry_id, media_url, media_type, max_order + 1)
)
new_id = conn.execute(
"SELECT id FROM diary_media WHERE diary_id=? ORDER BY id DESC LIMIT 1",
(entry_id,)
).fetchone()["id"]
return {"id": new_id, "url": media_url, "media_type": media_type, "sort_order": max_order + 1}
@router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
async def delete_media_item(dog_id: int, entry_id: int, media_id: int,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
"SELECT dm.id, dm.url FROM diary_media dm "
"JOIN diary d ON d.id = dm.diary_id "
"LEFT JOIN diary_dogs dd ON dd.diary_id = d.id "
"WHERE dm.id=? AND dm.diary_id=? AND (d.dog_id=? OR dd.dog_id=?)",
(media_id, entry_id, dog_id, dog_id)
).fetchone()
if not row:
raise HTTPException(404, "Medium nicht gefunden.")
file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/"))
try: os.remove(file_path)
except OSError: pass
conn.execute("DELETE FROM diary_media WHERE id=?", (media_id,))
@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204) @router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204)
async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user)): async def delete_media_legacy(dog_id: int, entry_id: int, user=Depends(get_current_user)):
"""Legacy-Endpoint: löscht media_url aus dem diary-Datensatz (Rückwärtskompatibilität)."""
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
row = conn.execute( row = conn.execute(
@ -421,7 +484,3 @@ async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user
try: os.remove(path) try: os.remove(path)
except OSError: pass except OSError: pass
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,)) conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
return unique

View file

@ -1065,6 +1065,79 @@ html.modal-open {
} }
.diary-media-pick-btn .ph-icon { font-size: 1.5rem; } .diary-media-pick-btn .ph-icon { font-size: 1.5rem; }
/* Multi-Medien: Formular-Grid (Thumbnails beim Erstellen/Bearbeiten) */
.diary-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.diary-media-thumb-wrap {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--c-surface-2);
}
.diary-media-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.diary-media-thumb-del {
position: absolute;
top: var(--space-1);
right: var(--space-1);
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,.55);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
}
.diary-media-thumb-del .ph-icon { font-size: .9rem; }
/* Medien-Zähler-Badge auf der Karte */
.diary-card-photo { position: relative; }
.diary-card-media-count {
position: absolute;
bottom: var(--space-1);
right: var(--space-1);
background: rgba(0,0,0,.55);
color: #fff;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
padding: 2px 6px;
border-radius: 12px;
pointer-events: none;
}
/* Detail-Ansicht: horizontale Scroll-Galerie */
.diary-gallery {
display: flex;
gap: var(--space-2);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
border-radius: var(--radius-md);
}
.diary-gallery-item {
flex: 0 0 auto;
width: min(80vw, 320px);
max-height: 260px;
object-fit: cover;
border-radius: var(--radius-md);
scroll-snap-align: start;
display: block;
}
.diary-card-video-thumb { .diary-card-video-thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;

View file

@ -124,6 +124,9 @@ const API = (() => {
deleteMedia(dogId, id) { deleteMedia(dogId, id) {
return del(`/dogs/${dogId}/diary/${id}/media`); return del(`/dogs/${dogId}/diary/${id}/media`);
}, },
deleteMediaItem(dogId, entryId, mediaId) {
return del(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}`);
},
nearby(dogId, lat, lon) { nearby(dogId, lat, lon) {
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`); return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
}, },

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '179'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '181'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {

View file

@ -35,6 +35,18 @@ window.Page_diary = (() => {
: `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`; : `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`;
} }
/** Alle Mediendateien eines Eintrags normalisiert als Array zurückgeben.
* Rückwärtskompatibel: wenn media_items leer, aber media_url gesetzt altes Format. */
function _allMedia(entry) {
const items = entry.media_items || [];
if (items.length > 0) return items;
if (entry.media_url) {
return [{ id: null, url: entry.media_url,
media_type: _isVideo(entry.media_url) ? 'video' : 'image', sort_order: 0 }];
}
return [];
}
const TYPEN = { const TYPEN = {
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' }, eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' }, foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' },
@ -292,11 +304,15 @@ window.Page_diary = (() => {
const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : '';
const tags = (e.tags || []).slice(0, 4); const tags = (e.tags || []).slice(0, 4);
const photo = e.media_url const allMedia = _allMedia(e);
const firstMedia = allMedia[0] || null;
const mediaCount = allMedia.length;
const photo = firstMedia
? `<div class="diary-card-photo"> ? `<div class="diary-card-photo">
${_isVideo(e.media_url) ${firstMedia.media_type === 'video'
? `<div class="diary-card-video-thumb"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg></div>` ? `<div class="diary-card-video-thumb"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg></div>`
: `<img src="${e.media_url}" alt="Foto" loading="lazy">`} : `<img src="${firstMedia.url}" alt="Foto" loading="lazy">`}
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>` </div>`
: ''; : '';
@ -362,8 +378,16 @@ window.Page_diary = (() => {
const isMile = entry.is_milestone || entry.typ === 'meilenstein'; const isMile = entry.is_milestone || entry.typ === 'meilenstein';
const tags = (entry.tags || []); const tags = (entry.tags || []);
const photo = entry.media_url const allMedia = _allMedia(entry);
? _mediaHtml(entry.media_url, 'margin-bottom:var(--space-4)') const photo = allMedia.length > 0
? (allMedia.length === 1
? _mediaHtml(allMedia[0].url, 'margin-bottom:var(--space-4)')
: `<div class="diary-gallery" style="margin-bottom:var(--space-4)">
${allMedia.map(m => m.media_type === 'video'
? `<video src="${m.url}" controls playsinline class="diary-gallery-item"></video>`
: `<img src="${m.url}" alt="Foto" class="diary-gallery-item">`
).join('')}
</div>`)
: ''; : '';
// Hunde-Anzeige wenn mehrere beteiligt // Hunde-Anzeige wenn mehrere beteiligt
@ -506,9 +530,9 @@ window.Page_diary = (() => {
</div> </div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)"> <div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button> <button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary flex-1" id="diary-location-btn"> <button type="button" class="btn btn-secondary btn-sm" id="diary-location-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-btn-label">${entry?.gps_lat ? 'POI suchen' : 'GPS → POI suchen'}</span> <span id="diary-location-btn-label">POI suchen</span>
</button> </button>
</div> </div>
<div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div> <div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
@ -525,24 +549,20 @@ window.Page_diary = (() => {
</button> </button>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Foto / Video <span style="color:var(--c-text-secondary)">(optional)</span></label> <label class="form-label">Fotos / Videos <span style="color:var(--c-text-secondary)">(optional)</span></label>
${isEdit && entry.media_url ? ` <!-- Bestehende Medien (Edit-Modus) -->
<div id="diary-current-media" style="position:relative;margin-bottom:var(--space-2)"> <div id="diary-existing-media"></div>
${_mediaHtml(entry.media_url, 'max-height:200px;object-fit:cover')}
<button type="button" class="btn btn-danger btn-sm" id="diary-media-delete" <!-- Neue Medien: Vorschau-Grid -->
style="position:absolute;top:var(--space-2);right:var(--space-2)"> <div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
${UI.icon('trash')}
</button>
</div>
` : ''}
<!-- versteckte Inputs --> <!-- versteckte Inputs -->
<input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none"> <input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none">
<input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none"> <input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none">
<!-- Auswahlbuttons immer sichtbar --> <!-- Auswahlbuttons immer sichtbar -->
<div id="diary-media-btns" class="diary-media-picker"> <div class="diary-media-picker" style="margin-top:var(--space-2)">
<button type="button" class="diary-media-pick-btn" id="diary-btn-camera"> <button type="button" class="diary-media-pick-btn" id="diary-btn-camera">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
Kamera Kamera
@ -556,14 +576,6 @@ window.Page_diary = (() => {
Datei Datei
</button> </button>
</div> </div>
<div id="diary-media-preview" style="display:none;margin-top:var(--space-2);position:relative">
<img id="diary-photo-preview" style="display:none;width:100%;max-height:200px;object-fit:cover;border-radius:var(--radius-md)">
<video id="diary-video-preview" style="display:none;width:100%;max-height:200px;border-radius:var(--radius-md)" controls playsinline></video>
<button type="button" id="diary-preview-clear"
style="position:absolute;top:var(--space-2);right:var(--space-2)"
class="btn btn-danger btn-sm">${UI.icon('x')}</button>
</div>
</div> </div>
</form> </form>
`; `;
@ -587,108 +599,118 @@ window.Page_diary = (() => {
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist // Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150); setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// Media-Inputs + Vorschau // ---- Multi-Media-Verwaltung ----
const mediaInput = document.getElementById('diary-media-input'); const mediaInput = document.getElementById('diary-media-input');
const cameraInput = document.getElementById('diary-camera-input'); const cameraInput = document.getElementById('diary-camera-input');
const photoPreview = document.getElementById('diary-photo-preview');
const videoPreview = document.getElementById('diary-video-preview');
const previewWrap = document.getElementById('diary-media-preview');
const mediaBtns = document.getElementById('diary-media-btns');
function _showPreview(file) { // Neue Dateien die noch nicht hochgeladen wurden
if (!file) return; const _newFiles = [];
previewWrap.style.display = '';
if (file.type.startsWith('video/')) { function _renderNewGrid() {
photoPreview.style.display = 'none'; const grid = document.getElementById('diary-new-media-grid');
videoPreview.style.display = ''; if (!grid) return;
videoPreview.src = URL.createObjectURL(file); if (_newFiles.length === 0) { grid.style.display = 'none'; grid.innerHTML = ''; return; }
} else { grid.style.display = '';
videoPreview.style.display = 'none'; grid.innerHTML = _newFiles.map((f, i) => {
photoPreview.style.display = ''; const objUrl = URL.createObjectURL(f);
photoPreview.src = URL.createObjectURL(file); const thumb = f.type.startsWith('video/')
} ? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${objUrl}" alt="" class="diary-media-thumb">`;
return `<div class="diary-media-thumb-wrap" data-new-idx="${i}">
${thumb}
<button type="button" class="diary-media-thumb-del" data-new-idx="${i}"
aria-label="Entfernen">${UI.icon('x')}</button>
</div>`;
}).join('');
grid.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.newIdx);
_newFiles.splice(idx, 1);
_renderNewGrid();
});
});
} }
function _showAlbumBtn(file) { // Bestehende Medien im Edit-Modus rendern
// Vorherigen Button entfernen falls vorhanden function _renderExistingMedia() {
document.getElementById('diary-save-album-btn')?.remove(); const wrap = document.getElementById('diary-existing-media');
// Nur anbieten wenn Share-API File-Support hat ODER als Download-Fallback if (!wrap) return;
const canShare = navigator.canShare && navigator.canShare({ files: [file] }); const items = isEdit ? _allMedia(entry) : [];
const canDownload = true; // <a download> funktioniert immer als Fallback if (items.length === 0) { wrap.innerHTML = ''; return; }
if (!canShare && !canDownload) return; const grid = `<div class="diary-media-grid" style="margin-bottom:var(--space-2)">
const btn = document.createElement('button'); ${items.map(m => `
btn.type = 'button'; <div class="diary-media-thumb-wrap" data-media-id="${m.id || ''}">
btn.id = 'diary-save-album-btn'; ${m.media_type === 'video'
btn.className = 'btn btn-secondary btn-sm'; ? `<video src="${m.url}" class="diary-media-thumb" muted playsinline></video>`
btn.style.cssText = 'display:flex;align-items:center;gap:var(--space-1);margin-top:var(--space-2);width:100%'; : `<img src="${m.url}" alt="" class="diary-media-thumb">`}
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg> ${m.id != null
<span>${canShare ? 'Zum Fotoalbum hinzufügen' : 'Foto herunterladen'}</span>`; ? `<button type="button" class="diary-media-thumb-del" data-media-id="${m.id}"
btn.addEventListener('click', () => UI.saveToAlbum(file)); aria-label="Entfernen">${UI.icon('x')}</button>`
previewWrap.after(btn); : `<button type="button" class="diary-media-thumb-del" data-legacy="1"
aria-label="Entfernen">${UI.icon('x')}</button>`}
</div>`).join('')}
</div>`;
wrap.innerHTML = grid;
wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', async () => {
const wrap2 = btn.closest('.diary-media-thumb-wrap');
const mediaId = btn.dataset.mediaId ? parseInt(btn.dataset.mediaId) : null;
const isLegacy = !!btn.dataset.legacy;
btn.disabled = true;
try {
if (mediaId != null) {
await API.diary.deleteMediaItem(_appState.activeDog.id, entry.id, mediaId);
// aus entry.media_items entfernen
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
} else if (isLegacy) {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
}
wrap2.remove();
UI.toast.success('Medium entfernt.');
} catch (e) {
btn.disabled = false;
UI.toast.error(e.message || 'Fehler beim Löschen.');
}
});
});
}
_renderExistingMedia();
function _addFiles(fileList) {
for (const f of fileList) _newFiles.push(f);
_renderNewGrid();
} }
mediaInput?.addEventListener('change', () => { function _openPicker(opts = {}) {
_showPreview(mediaInput.files[0]);
// Kein Album-Button bei Mediathek-Picks (Fotos sind bereits dort)
document.getElementById('diary-save-album-btn')?.remove();
});
cameraInput?.addEventListener('change', () => {
// Auswahl in mediaInput spiegeln damit Submit-Handler nur einen Ort abfragt
const dt = new DataTransfer();
if (cameraInput.files[0]) dt.items.add(cameraInput.files[0]);
mediaInput.files = dt.files;
_showPreview(cameraInput.files[0]);
// Album-Button nach Kamera-Aufnahme anzeigen
if (cameraInput.files[0]) _showAlbumBtn(cameraInput.files[0]);
});
document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click());
document.getElementById('diary-btn-library')?.addEventListener('click', () => {
// Kein capture → iOS zeigt Mediathek-Auswahl, Android zeigt Galerie
const tmp = document.createElement('input'); const tmp = document.createElement('input');
tmp.type = 'file'; tmp.accept = 'image/*,video/*'; tmp.style.display = 'none'; tmp.type = 'file'; tmp.accept = 'image/*,video/*'; tmp.style.display = 'none';
if (opts.capture) tmp.setAttribute('capture', opts.capture);
if (opts.noAccept) tmp.removeAttribute('accept');
tmp.addEventListener('change', () => { tmp.addEventListener('change', () => {
const dt = new DataTransfer(); _addFiles(tmp.files);
if (tmp.files[0]) dt.items.add(tmp.files[0]);
mediaInput.files = dt.files;
_showPreview(tmp.files[0]);
tmp.remove(); tmp.remove();
}); });
document.body.appendChild(tmp); document.body.appendChild(tmp);
tmp.click(); tmp.click();
}
cameraInput?.addEventListener('change', () => {
if (cameraInput.files.length) {
_addFiles(cameraInput.files);
cameraInput.value = '';
}
}); });
document.getElementById('diary-btn-file')?.addEventListener('click', () => { mediaInput?.addEventListener('change', () => {
mediaInput.removeAttribute('accept'); if (mediaInput.files.length) {
mediaInput.click(); _addFiles(mediaInput.files);
mediaInput.setAttribute('accept', 'image/*,video/*'); mediaInput.value = '';
}
}); });
document.getElementById('diary-preview-clear')?.addEventListener('click', () => { document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click());
previewWrap.style.display = 'none'; document.getElementById('diary-btn-library')?.addEventListener('click', () => _openPicker({}));
photoPreview.src = ''; videoPreview.src = ''; document.getElementById('diary-btn-file') ?.addEventListener('click', () => _openPicker({ noAccept: true }));
mediaInput.value = '';
document.getElementById('diary-save-album-btn')?.remove();
});
// "Entfernen"-Button löscht Medium direkt
document.getElementById('diary-media-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: `${_isVideo(entry.media_url) ? 'Video' : 'Foto'} entfernen?`,
message: 'Das Medium wird dauerhaft gelöscht.',
confirmText: 'Entfernen', danger: true,
});
if (!ok) return;
try {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
const mediaDiv = document.getElementById('diary-current-media');
if (mediaDiv) mediaDiv.remove();
const replaceBtn = document.getElementById('diary-media-replace');
if (replaceBtn) replaceBtn.remove();
mediaInput.style.display = '';
UI.toast.success('Medium entfernt.');
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
@ -752,7 +774,7 @@ window.Page_diary = (() => {
_locLat = null; _locLon = null; _locName = null; _locLat = null; _locLon = null; _locName = null;
document.getElementById('diary-location-chip-wrap').style.display = 'none'; document.getElementById('diary-location-chip-wrap').style.display = 'none';
document.getElementById('diary-location-suggestions').style.display = 'none'; document.getElementById('diary-location-suggestions').style.display = 'none';
document.getElementById('diary-location-btn-label').textContent = 'GPS → POI suchen'; document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; } if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); } if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); }
}); });
@ -882,33 +904,43 @@ window.Page_diary = (() => {
location_name: _locName, location_name: _locName,
}; };
const mediaFile = mediaInput?.files[0]; async function _uploadNewFiles(entryId) {
let failCount = 0;
const uploaded = [];
for (const file of _newFiles) {
try {
const formData = new FormData();
formData.append('file', file);
const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData);
uploaded.push(m);
} catch {
failCount++;
}
}
if (failCount > 0) {
UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`);
}
return uploaded;
}
if (isEdit) { if (isEdit) {
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload); const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload);
if (mediaFile) { if (_newFiles.length > 0) {
try { const uploaded = await _uploadNewFiles(entry.id);
const fd2 = new FormData(); if (!updated.media_items) updated.media_items = [];
fd2.append('file', mediaFile); updated.media_items.push(...uploaded);
const media = await API.diary.uploadMedia(_appState.activeDog.id, entry.id, fd2); } else {
updated.media_url = media.media_url; // media_items aus dem aktuellen entry-State übernehmen (evtl. gelöscht via X-Button)
} catch { updated.media_items = entry.media_items || updated.media_items || [];
UI.toast.warning('Gespeichert, Medium konnte nicht hochgeladen werden.'); updated.media_url = entry.media_url ?? updated.media_url;
}
} }
_updateEntryInList(updated); _updateEntryInList(updated);
UI.toast.success('Eintrag gespeichert.'); UI.toast.success('Eintrag gespeichert.');
} else { } else {
const created = await API.diary.create(_appState.activeDog.id, payload); const created = await API.diary.create(_appState.activeDog.id, payload);
if (mediaFile) { if (_newFiles.length > 0) {
try { const uploaded = await _uploadNewFiles(created.id);
const fd2 = new FormData(); created.media_items = uploaded;
fd2.append('file', mediaFile);
const media = await API.diary.uploadMedia(_appState.activeDog.id, created.id, fd2);
created.media_url = media.media_url;
} catch {
UI.toast.warning('Eintrag erstellt, Medium konnte nicht hochgeladen werden.');
}
} }
_entries.unshift(created); _entries.unshift(created);
UI.toast.success('Eintrag erstellt.'); UI.toast.success('Eintrag erstellt.');

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v210'; const CACHE_VERSION = 'by-v211';
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