diff --git a/backend/database.py b/backend/database.py
index 6136174..7c13a8f 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -466,6 +466,11 @@ def _migrate(conn_factory):
("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"),
# Tagebuch: Ortsname (POI/Adresse)
("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:
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);
""")
+ # 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)
conn.executescript("""
CREATE TABLE IF NOT EXISTS walk_invitations (
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index e99b161..b242426 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -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["tags"] = json.loads(e["tags"]) if e["tags"] else []
- e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
+ e["tags"] = json.loads(e["tags"]) if e["tags"] else []
+ e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
+ e["media_items"] = (media_map or {}).get(e["id"], [])
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)
).fetchall()
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)
@@ -158,9 +179,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
(dog_id,)
).fetchone()
_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:
@@ -302,9 +324,10 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
).fetchone()
if not row:
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}")
@@ -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)
_set_dog_ids(conn, entry_id, all_dogs)
- row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
- dogs_map = _fetch_dog_ids(conn, [entry_id])
+ row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ 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)
@@ -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")
async def upload_media(dog_id: int, entry_id: int,
file: UploadFile = File(...),
@@ -386,29 +420,58 @@ async def upload_media(dog_id: int, entry_id: int,
".mp4",".mov",".webm",".m4v"}:
raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
- ext = os.path.splitext(file.filename or "")[1] or ".jpg"
- filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
- path = os.path.join(MEDIA_DIR, "diary", filename)
+ ext = os.path.splitext(file.filename or "")[1] or ".jpg"
+ filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
+ 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)
with open(path, "wb") as f:
f.write(await file.read())
- # Altes Medium von Disk löschen wenn vorhanden
- 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))
+ media_url = f"/media/diary/{filename}"
- 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)
-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:
_own_dog(dog_id, user["id"], conn)
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)
except OSError: pass
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
-
-
-
- return unique
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index b75b4b4..d29f76d 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -1065,6 +1065,79 @@ html.modal-open {
}
.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 {
width: 100%;
height: 100%;
diff --git a/backend/static/js/api.js b/backend/static/js/api.js
index eba9ac4..46f5ac3 100644
--- a/backend/static/js/api.js
+++ b/backend/static/js/api.js
@@ -124,6 +124,9 @@ const API = (() => {
deleteMedia(dogId, id) {
return del(`/dogs/${dogId}/diary/${id}/media`);
},
+ deleteMediaItem(dogId, entryId, mediaId) {
+ return del(`/dogs/${dogId}/diary/${entryId}/media/${mediaId}`);
+ },
nearby(dogId, lat, lon) {
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
},
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 8ec5eb4..d84fbb9 100644
--- a/backend/static/js/app.js
+++ b/backend/static/js/app.js
@@ -3,7 +3,7 @@
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 = (() => {
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js
index f61e741..196eada 100644
--- a/backend/static/js/pages/diary.js
+++ b/backend/static/js/pages/diary.js
@@ -35,6 +35,18 @@ window.Page_diary = (() => {
: ``;
}
+ /** 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 = {
eintrag: { label: 'Eintrag', icon: '' },
foto: { label: 'Foto', icon: '' },
@@ -292,11 +304,15 @@ window.Page_diary = (() => {
const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : '';
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
? `