Feature: Gesundheit Multi-Media — health_media Tabelle + Multi-Upload UI
- Neue Tabelle health_media (Migration in database.py) analog zu diary_media
- GET-Endpoints geben media_items:[{id,url,media_type}] zurück (datei_url bleibt für Rückwärtskompatibilität)
- POST /health/{id}/media und DELETE /health/{id}/media/{media_id} Endpoints
- Multi-Upload-Bereich im Formular: Thumbnails für Bilder, PDF-Icon, X-Button zum Entfernen
- Galerie in Detailansicht: Bilder klickbar/zoombar, PDFs als Link
- CSS-Klassen health-media-grid/thumb/gallery in components.css
- SW by-v213, APP_VER 187
This commit is contained in:
parent
fa0fcbf8c9
commit
aa70a838f2
5 changed files with 347 additions and 42 deletions
|
|
@ -11,6 +11,8 @@ from auth import get_current_user
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
|
||||||
|
|
||||||
# Erlaubte Typen
|
# Erlaubte Typen
|
||||||
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"}
|
TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"}
|
||||||
|
|
||||||
|
|
@ -70,7 +72,7 @@ class HealthUpdate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hilfsfunktion: Zugriffscheck Dog → User
|
# Hilfsfunktionen
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _check_dog_owner(conn, dog_id: int, user_id: int):
|
def _check_dog_owner(conn, dog_id: int, user_id: int):
|
||||||
dog = conn.execute(
|
dog = conn.execute(
|
||||||
|
|
@ -81,6 +83,30 @@ def _check_dog_owner(conn, dog_id: int, user_id: int):
|
||||||
return dog
|
return dog
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_media_items(conn, entry_ids: list) -> dict:
|
||||||
|
"""Gibt {health_id: [{id, url, media_type}, ...]} zurück."""
|
||||||
|
if not entry_ids:
|
||||||
|
return {}
|
||||||
|
ph = ",".join("?" * len(entry_ids))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, health_id, url, media_type FROM health_media "
|
||||||
|
f"WHERE health_id IN ({ph}) ORDER BY health_id, sort_order",
|
||||||
|
entry_ids
|
||||||
|
).fetchall()
|
||||||
|
result = {}
|
||||||
|
for r in rows:
|
||||||
|
result.setdefault(r["health_id"], []).append({
|
||||||
|
"id": r["id"], "url": r["url"], "media_type": r["media_type"]
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_with_media(row, media_map: dict) -> dict:
|
||||||
|
e = dict(row)
|
||||||
|
e["media_items"] = media_map.get(e["id"], [])
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/dogs/{dog_id}/health
|
# GET /api/dogs/{dog_id}/health
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -99,7 +125,9 @@ async def list_health(dog_id: int, typ: Optional[str] = None,
|
||||||
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
|
"SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
ids = [r["id"] for r in rows]
|
||||||
|
media_map = _fetch_media_items(conn, ids)
|
||||||
|
return [_entry_with_media(r, media_map) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -130,7 +158,8 @@ async def create_health(dog_id: int, data: HealthCreate,
|
||||||
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
|
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row)
|
media_map = _fetch_media_items(conn, [row["id"]])
|
||||||
|
return _entry_with_media(row, media_map)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -155,7 +184,8 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
|
||||||
values = list(updates.values()) + [entry_id]
|
values = list(updates.values()) + [entry_id]
|
||||||
conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
|
conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
|
||||||
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
|
row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
|
||||||
return dict(row)
|
media_map = _fetch_media_items(conn, [entry_id])
|
||||||
|
return _entry_with_media(row, media_map)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -243,6 +273,77 @@ async def upload_dokument(
|
||||||
return {"datei_url": datei_url, "datei_typ": datei_typ}
|
return {"datei_url": datei_url, "datei_typ": datei_typ}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/dogs/{dog_id}/health/{entry_id}/media — Datei-Upload (Multi)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/{dog_id}/health/{entry_id}/media")
|
||||||
|
async def upload_media(
|
||||||
|
dog_id: int,
|
||||||
|
entry_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
with db() as conn:
|
||||||
|
_check_dog_owner(conn, dog_id, user["id"])
|
||||||
|
entry = conn.execute(
|
||||||
|
"SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
||||||
|
).fetchone()
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||||
|
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
|
||||||
|
|
||||||
|
filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
path = os.path.join(MEDIA_DIR, "health", filename)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(await file.read())
|
||||||
|
|
||||||
|
media_url = f"/media/health/{filename}"
|
||||||
|
media_type = "pdf" if ext == ".pdf" else "image"
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
max_order = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), -1) FROM health_media WHERE health_id=?",
|
||||||
|
(entry_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO health_media (health_id, url, media_type, sort_order) VALUES (?,?,?,?)",
|
||||||
|
(entry_id, media_url, media_type, max_order + 1)
|
||||||
|
)
|
||||||
|
new_id = conn.execute(
|
||||||
|
"SELECT id FROM health_media WHERE health_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}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DELETE /api/dogs/{dog_id}/health/{entry_id}/media/{media_id}
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.delete("/{dog_id}/health/{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:
|
||||||
|
_check_dog_owner(conn, dog_id, user["id"])
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT hm.id, hm.url FROM health_media hm "
|
||||||
|
"JOIN health h ON h.id = hm.health_id "
|
||||||
|
"WHERE hm.id=? AND hm.health_id=? AND h.dog_id=?",
|
||||||
|
(media_id, entry_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 health_media WHERE id=?", (media_id,))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf
|
# GET /api/dogs/{dog_id}/health/gewicht — Gewichtsverlauf
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1419,6 +1419,104 @@ html.modal-open {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Health Multi-Media — Upload-Grid im Formular */
|
||||||
|
.health-media-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.health-media-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--c-border-light);
|
||||||
|
}
|
||||||
|
.health-media-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.health-media-thumb-pdf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
}
|
||||||
|
.health-media-thumb-pdf span {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
.health-media-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.health-media-thumb--pending {
|
||||||
|
opacity: 0.75;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.health-media-thumb--pending small {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health Media-Galerie in Detail-Modal */
|
||||||
|
.health-media-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.health-media-gallery-img {
|
||||||
|
display: block;
|
||||||
|
width: calc(50% - var(--space-1));
|
||||||
|
max-width: 160px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.health-media-gallery-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.health-media-gallery-pdf {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
/* Detail-Dialog DL */
|
/* Detail-Dialog DL */
|
||||||
.health-detail-dl {
|
.health-detail-dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '186'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '187'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -813,30 +813,42 @@ window.Page_health = (() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = entries.map(e => {
|
const items = entries.map(e => {
|
||||||
const isPdf = e.datei_typ === 'pdf';
|
// media_items bevorzugen, legacy datei_url als Fallback
|
||||||
const hasFile = !!e.datei_url;
|
const mediaList = e.media_items?.length
|
||||||
|
? e.media_items
|
||||||
|
: (e.datei_url ? [{ id: null, url: e.datei_url, media_type: e.datei_typ || 'image' }] : []);
|
||||||
|
const firstImg = mediaList.find(m => m.media_type !== 'pdf');
|
||||||
|
const hasPdf = mediaList.some(m => m.media_type === 'pdf');
|
||||||
|
const count = mediaList.length;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
<div class="health-card" data-id="${e.id}" data-action="open-entry">
|
||||||
${hasFile && !isPdf
|
${firstImg
|
||||||
? `<img src="${e.datei_url}" class="health-doc-thumb" alt="Vorschau"
|
? `<img src="${_esc(firstImg.url)}" class="health-doc-thumb" alt="Vorschau"
|
||||||
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
|
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
|
||||||
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
|
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
|
||||||
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
|
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
|
||||||
<div class="health-card-body">
|
<div class="health-card-body">
|
||||||
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
|
||||||
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
|
<div class="health-card-meta">
|
||||||
|
${UI.time.format(e.datum + 'T00:00:00')}
|
||||||
|
${count > 1 ? ` · ${count} Dateien` : ''}
|
||||||
|
</div>
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
${hasFile
|
${count
|
||||||
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
|
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
|
||||||
<a href="${e.datei_url}" target="_blank" rel="noopener"
|
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
|
||||||
|
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
|
||||||
class="btn btn-secondary btn-sm" style="display:inline-flex"
|
class="btn btn-secondary btn-sm" style="display:inline-flex"
|
||||||
onclick="event.stopPropagation()">
|
onclick="event.stopPropagation()">
|
||||||
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF
|
||||||
</a>
|
</a>`
|
||||||
<button class="btn btn-danger btn-sm" data-action="delete-dok" data-id="${e.id}"
|
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
|
||||||
onclick="event.stopPropagation()" aria-label="Dokument löschen">
|
class="btn btn-secondary btn-sm" style="display:inline-flex"
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
onclick="event.stopPropagation()">
|
||||||
</button>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild
|
||||||
|
</a>`
|
||||||
|
).join('')}
|
||||||
</div>`
|
</div>`
|
||||||
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
|
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -902,14 +914,29 @@ window.Page_health = (() => {
|
||||||
const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0];
|
const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0];
|
||||||
const fields = _detailFields(entry);
|
const fields = _detailFields(entry);
|
||||||
|
|
||||||
|
// Media-Items zusammenstellen (neue + legacy)
|
||||||
|
const mediaItems = entry.media_items?.length
|
||||||
|
? entry.media_items
|
||||||
|
: (entry.datei_url ? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image' }] : []);
|
||||||
|
|
||||||
|
const mediaHtml = mediaItems.length
|
||||||
|
? `<div class="health-media-gallery" style="margin-top:var(--space-4)">
|
||||||
|
${mediaItems.map(m => m.media_type === 'pdf'
|
||||||
|
? `<a href="${_esc(m.url)}" target="_blank" rel="noopener"
|
||||||
|
class="btn btn-secondary btn-sm health-media-gallery-pdf">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen
|
||||||
|
</a>`
|
||||||
|
: `<a href="${_esc(m.url)}" target="_blank" rel="noopener" class="health-media-gallery-img">
|
||||||
|
<img src="${_esc(m.url)}" alt="Bild" loading="lazy">
|
||||||
|
</a>`
|
||||||
|
).join('')}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<div class="health-detail">
|
<div class="health-detail">
|
||||||
${fields}
|
${fields}
|
||||||
${entry.datei_url
|
${mediaHtml}
|
||||||
? (entry.datei_typ === 'pdf'
|
|
||||||
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen</a>`
|
|
||||||
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
|
|
||||||
: ''}
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
|
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
|
||||||
`;
|
`;
|
||||||
|
|
@ -989,15 +1016,42 @@ window.Page_health = (() => {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const uploadField = t === 'dokument' ? `
|
// Multi-Upload-Bereich — zeige vorhandene media_items + neuen Upload
|
||||||
<div class="form-group">
|
const existingMedia = (entry?.media_items || []);
|
||||||
|
const legacyFile = (!existingMedia.length && entry?.datei_url)
|
||||||
|
? [{ id: null, url: entry.datei_url, media_type: entry.datei_typ || 'image', _legacy: true }]
|
||||||
|
: [];
|
||||||
|
const allMedia = [...existingMedia, ...legacyFile];
|
||||||
|
|
||||||
|
const mediaThumbsHtml = allMedia.map(m => {
|
||||||
|
const isImg = m.media_type !== 'pdf';
|
||||||
|
const removeBtn = m.id
|
||||||
|
? `<button type="button" class="health-media-remove" data-media-id="${m.id}"
|
||||||
|
title="Entfernen" aria-label="Datei entfernen">×</button>`
|
||||||
|
: '';
|
||||||
|
return `<div class="health-media-thumb" data-media-id="${m.id || ''}">
|
||||||
|
${isImg
|
||||||
|
? `<img src="${_esc(m.url)}" alt="Vorschau">`
|
||||||
|
: `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div>`}
|
||||||
|
${removeBtn}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const uploadField = `
|
||||||
|
<div class="form-group" id="health-media-section">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
Datei (JPG, PNG, PDF)
|
Dateien (Bilder / PDFs)
|
||||||
${UI.help('PDF oder Foto — z.B. Impfpass, Röntgenbild, Befund.')}
|
${UI.help('Befunde, Röntgenbilder, Laborwerte — mehrere Dateien möglich.')}
|
||||||
</label>
|
</label>
|
||||||
<input class="form-control" type="file" name="datei" accept="image/*,.pdf">
|
<div class="health-media-grid" id="health-media-grid">${mediaThumbsHtml}</div>
|
||||||
|
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei hinzufügen
|
||||||
|
<input type="file" name="datei_neu" accept="image/*,.pdf" multiple
|
||||||
|
style="position:absolute;opacity:0;width:1px;height:1px" id="health-file-input">
|
||||||
|
</label>
|
||||||
|
<div id="health-file-pending" style="margin-top:var(--space-2);display:flex;flex-wrap:wrap;gap:var(--space-2)"></div>
|
||||||
</div>
|
</div>
|
||||||
` : '';
|
`;
|
||||||
|
|
||||||
const body = `
|
const body = `
|
||||||
<form id="health-form" autocomplete="off">
|
<form id="health-form" autocomplete="off">
|
||||||
|
|
@ -1030,6 +1084,48 @@ window.Page_health = (() => {
|
||||||
_activeTab = 'praxen';
|
_activeTab = 'praxen';
|
||||||
_renderTab();
|
_renderTab();
|
||||||
});
|
});
|
||||||
|
// File-Input: Vorschau für ausstehende Uploads
|
||||||
|
const fileInput = document.getElementById('health-file-input');
|
||||||
|
const pendingBox = document.getElementById('health-file-pending');
|
||||||
|
if (fileInput && pendingBox) {
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
pendingBox.innerHTML = '';
|
||||||
|
Array.from(fileInput.files || []).forEach(f => {
|
||||||
|
const isPdf = f.name.toLowerCase().endsWith('.pdf');
|
||||||
|
const thumb = document.createElement('div');
|
||||||
|
thumb.className = 'health-media-thumb health-media-thumb--pending';
|
||||||
|
if (isPdf) {
|
||||||
|
thumb.innerHTML = `<div class="health-media-thumb-pdf"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span>PDF</span></div><small>${_esc(f.name.slice(0, 18))}</small>`;
|
||||||
|
} else {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = URL.createObjectURL(f);
|
||||||
|
thumb.appendChild(img);
|
||||||
|
}
|
||||||
|
pendingBox.appendChild(thumb);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// X-Buttons für vorhandene Media-Items
|
||||||
|
document.querySelectorAll('#health-media-grid .health-media-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const mediaId = parseInt(btn.dataset.mediaId);
|
||||||
|
const dogId = _appState.activeDog.id;
|
||||||
|
if (!mediaId || !entry?.id) return;
|
||||||
|
try {
|
||||||
|
await API.health.deleteMedia(dogId, entry.id, mediaId);
|
||||||
|
// Aus entry.media_items entfernen
|
||||||
|
if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId);
|
||||||
|
btn.closest('.health-media-thumb').remove();
|
||||||
|
// Auch in _data aktualisieren
|
||||||
|
const list = _data[t] || [];
|
||||||
|
const idx = list.findIndex(x => x.id === entry.id);
|
||||||
|
if (idx !== -1) list[idx].media_items = (list[idx].media_items || []).filter(m => m.id !== mediaId);
|
||||||
|
UI.toast.success('Datei entfernt.');
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error('Fehler beim Löschen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
|
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
@ -1070,16 +1166,26 @@ window.Page_health = (() => {
|
||||||
UI.toast.success('Eintrag erstellt.');
|
UI.toast.success('Eintrag erstellt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datei-Upload für Dokumente
|
// Multi-File-Upload
|
||||||
if (t === 'dokument' && form.querySelector('[name="datei"]')?.files[0]) {
|
const fileInput = form.querySelector('[name="datei_neu"]');
|
||||||
|
const files = fileInput ? Array.from(fileInput.files || []) : [];
|
||||||
|
if (files.length) {
|
||||||
|
const dogId = _appState.activeDog.id;
|
||||||
|
if (!saved.media_items) saved.media_items = [];
|
||||||
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const fd = new FormData();
|
||||||
formData.append('file', form.querySelector('[name="datei"]').files[0]);
|
fd.append('file', f);
|
||||||
const res = await API.health.uploadDokument(_appState.activeDog.id, saved.id, formData);
|
const res = await API.health.uploadMedia(dogId, saved.id, fd);
|
||||||
saved.datei_url = res.datei_url;
|
saved.media_items.push({ id: res.id, url: res.url, media_type: res.media_type });
|
||||||
saved.datei_typ = res.datei_typ;
|
// Rückwärtskompatibilität: erste Datei auch als datei_url sichern
|
||||||
|
if (!saved.datei_url) {
|
||||||
|
saved.datei_url = res.url;
|
||||||
|
saved.datei_typ = res.media_type;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
UI.toast.warning('Eintrag erstellt, Datei konnte nicht hochgeladen werden.');
|
UI.toast.warning(`Datei "${f.name}" konnte nicht hochgeladen werden.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v212';
|
const CACHE_VERSION = 'by-v213';
|
||||||
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