diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index dc0fa53..5fd9e40 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -24,6 +24,7 @@ from feature_logger import log_feature_usage from caliper_composition import enrich_caliper_row_for_response, load_weight_rows from data_layer.activity_session_metrics import enrich_sessions_with_metrics from data_layer.utils import serialize_dates +from routers.photos import resolve_photo_path router = APIRouter(prefix="/api/export", tags=["export"]) logger = logging.getLogger(__name__) @@ -392,8 +393,8 @@ Datumsformat: YYYY-MM-DD cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,)) photos = [r2d(r) for r in cur.fetchall()] for i, photo in enumerate(photos): - photo_path = Path(PHOTOS_DIR) / photo['path'] - if photo_path.exists(): + photo_path = resolve_photo_path(photo.get('path')) + if photo_path and photo_path.exists(): filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}" zf.write(photo_path, f"photos/{filename}") diff --git a/backend/routers/photos.py b/backend/routers/photos.py index daa5ca9..65a6ef8 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -25,6 +25,26 @@ PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) +def resolve_photo_path(stored: Optional[str]) -> Optional[Path]: + """ + Map DB `path` to an existing file. Supports legacy rows (absolute path or nested) + and new rows (filename only under PHOTOS_DIR). + """ + if not stored or not str(stored).strip(): + return None + raw = str(stored).strip() + p = Path(raw) + if p.is_absolute() and p.exists(): + return p + cand = PHOTOS_DIR / raw + if cand.exists(): + return cand + cand2 = PHOTOS_DIR / Path(raw).name + if cand2.exists(): + return cand2 + return None + + @router.post("") async def upload_photo(file: UploadFile=File(...), date: str=Form(""), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): @@ -48,7 +68,8 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""), fid = str(uuid.uuid4()) ext = Path(file.filename).suffix or '.jpg' - path = PHOTOS_DIR / f"{fid}{ext}" + fname = f"{fid}{ext}" + path = PHOTOS_DIR / fname async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) # Convert empty string to NULL for date field @@ -56,8 +77,9 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""), with get_db() as conn: cur = get_cursor(conn) + # Nur Dateiname in DB — Auflösung über PHOTOS_DIR (auch für ältere absolute Pfade: resolve_photo_path) cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", - (fid,pid,photo_date,str(path))) + (fid,pid,photo_date,fname)) # Phase 2: Increment usage counter increment_feature_usage(pid, 'photos') @@ -67,14 +89,18 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""), @router.get("/{fid}") def get_photo(fid: str, session: dict=Depends(require_auth_flexible)): - """Get photo by ID. Auth via header or query param (for tags).""" + """Get photo by ID. Auth via header or query param ssetoken (for tags).""" + profile_id = str(session["profile_id"]) with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) + cur.execute("SELECT path, profile_id FROM photos WHERE id=%s", (fid,)) row = cur.fetchone() - if not row: raise HTTPException(404, "Photo not found") - photo_path = Path(PHOTOS_DIR) / row['path'] - if not photo_path.exists(): + if not row: + raise HTTPException(404, "Photo not found") + if str(row["profile_id"]) != profile_id: + raise HTTPException(404, "Photo not found") + photo_path = resolve_photo_path(row["path"]) + if not photo_path: raise HTTPException(404, "Photo file not found") return FileResponse(photo_path) @@ -86,5 +112,38 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict= with get_db() as conn: cur = get_cursor(conn) cur.execute( - "SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,)) + """ + SELECT * FROM photos WHERE profile_id=%s + ORDER BY date DESC NULLS LAST, created DESC + LIMIT 500 + """, + (pid,), + ) return [r2d(r) for r in cur.fetchall()] + + +@router.delete("/{fid}") +def delete_photo( + fid: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """Delete a photo (DB row + Datei auf der Platte).""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT path FROM photos WHERE id=%s AND profile_id=%s", + (fid, pid), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Photo not found") + photo_path = resolve_photo_path(row["path"]) + cur.execute("DELETE FROM photos WHERE id=%s AND profile_id=%s", (fid, pid)) + if photo_path and photo_path.exists(): + try: + photo_path.unlink() + except OSError as e: + logger.warning("Could not delete photo file %s: %s", photo_path, e) + return {"ok": True, "id": fid} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a5b44a1..f1ad5c1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import CaptureShell from './layouts/CaptureShell' import CaptureHub from './pages/CaptureHub' import WeightScreen from './pages/WeightScreen' import CircumScreen from './pages/CircumScreen' +import PhotosCapturePage from './pages/PhotosCapturePage' import CaliperScreen from './pages/CaliperScreen' import MeasureWizard from './pages/MeasureWizard' import History from './pages/History' @@ -223,6 +224,7 @@ function AppShell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx b/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx index 2ce4009..f97bb56 100644 --- a/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx +++ b/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx @@ -18,8 +18,8 @@ export default function ProgressPhotosWidget({ refreshTick = 0 }) { return (
Noch keine Fotos.
-
) diff --git a/frontend/src/config/captureNav.js b/frontend/src/config/captureNav.js index f815670..29bab26 100644 --- a/frontend/src/config/captureNav.js +++ b/frontend/src/config/captureNav.js @@ -26,6 +26,13 @@ export const CAPTURE_HUB_TILES = [ to: '/circum', color: '#1D9E75', }, + { + icon: '📷', + label: 'Fotos', + sub: 'Fortschrittsfotos hochladen und verwalten', + to: '/photos', + color: '#5C6BC0', + }, { icon: '📐', label: 'Caliper', diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index c0dc264..496aff3 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -6,7 +6,7 @@ import { XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine, PieChart, Pie, Cell } from 'recharts' -import { ChevronRight, Brain, ChevronDown, ChevronUp } from 'lucide-react' +import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { api } from '../utils/api' import { getBfCategory } from '../utils/calc' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' @@ -897,28 +897,128 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu } // ── Photo Grid ──────────────────────────────────────────────────────────────── +function monthKeyFromPhoto(p) { + if (p.date) return p.date.slice(0, 7) + return (p.created || '').slice(0, 7) || '0000-00' +} + +function sortPhotoDateKey(p) { + return (p.date || p.created || '').slice(0, 10) +} + function PhotoGrid() { - const [photos,setPhotos]=useState([]) - const [big,setBig]=useState(null) - useEffect(()=>{ api.listPhotos().then(setPhotos) },[]) - if(!photos.length) return + const [photos, setPhotos] = useState([]) + const [big, setBig] = useState(null) + + const load = () => api.listPhotos().then(setPhotos) + + useEffect(() => { + load() + }, []) + + const handleDelete = async (id) => { + if (!confirm('Dieses Foto löschen?')) return + try { + await api.deletePhoto(id) + if (big === id) setBig(null) + await load() + } catch (e) { + alert(e.message || 'Löschen fehlgeschlagen') + } + } + + if (!photos.length) { + return + } + + const sorted = [...photos].sort((a, b) => sortPhotoDateKey(b).localeCompare(sortPhotoDateKey(a))) + const byMonth = new Map() + for (const p of sorted) { + const mk = monthKeyFromPhoto(p) + if (!byMonth.has(mk)) byMonth.set(mk, []) + byMonth.get(mk).push(p) + } + const monthKeys = [...byMonth.keys()].sort((a, b) => b.localeCompare(a)) + return ( <> - {big&&
setBig(null)}> - -
} -
- {photos.map(p=>( -
- setBig(p.id)} alt=""/> -
- {p.date?.slice(0,10)||p.created?.slice(0,10)} -
+ {big && ( +
setBig(null)} + role="presentation" + > + +
+ )} + {monthKeys.map((mk) => ( +
+
+ {dayjs(`${mk}-01`).format('MMMM YYYY')}
- ))} -
+
+ {byMonth.get(mk).map((p) => ( +
+ setBig(p.id)} + alt="" + /> +
+ {p.date?.slice(0, 10) || p.created?.slice(0, 10)} +
+ +
+ ))} +
+
+ ))} ) } diff --git a/frontend/src/pages/NewMeasurement.jsx b/frontend/src/pages/NewMeasurement.jsx index 2e68984..9b4febf 100644 --- a/frontend/src/pages/NewMeasurement.jsx +++ b/frontend/src/pages/NewMeasurement.jsx @@ -102,7 +102,7 @@ export default function NewMeasurement() { if (id) { await api.updateMeasurement(id, payload) } else { const r = await api.createMeasurement(payload); mid = r.id } if (photoFile && mid) { - const pr = await api.uploadPhoto(photoFile, mid) + const pr = await api.uploadPhoto(photoFile, form.date) await api.updateMeasurement(mid, {photo_id: pr.id}) } setSaved(true); setTimeout(()=>nav('/'), 1200) diff --git a/frontend/src/pages/PhotosCapturePage.jsx b/frontend/src/pages/PhotosCapturePage.jsx new file mode 100644 index 0000000..fd77b93 --- /dev/null +++ b/frontend/src/pages/PhotosCapturePage.jsx @@ -0,0 +1,203 @@ +import { useState, useEffect, useRef } from 'react' +import { Camera, Trash2 } from 'lucide-react' +import dayjs from 'dayjs' +import { api } from '../utils/api' +import UsageBadge from '../components/UsageBadge' + +export default function PhotosCapturePage() { + const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD')) + const [photos, setPhotos] = useState([]) + const [photoUsage, setPhotoUsage] = useState(null) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [big, setBig] = useState(null) + const fileRef = useRef(null) + + const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([])) + + const loadUsage = () => { + api + .getFeatureUsage() + .then((features) => { + const f = features.find((x) => x.feature_id === 'photos') + setPhotoUsage(f) + }) + .catch(() => setPhotoUsage(null)) + } + + useEffect(() => { + load() + loadUsage() + }, []) + + const onPickFiles = async (e) => { + const files = Array.from(e.target.files || []) + e.target.value = '' + if (!files.length) return + setError(null) + setUploading(true) + try { + for (const file of files) { + await api.uploadPhoto(file, date) + } + await load() + await loadUsage() + } catch (err) { + setError(err.message || 'Upload fehlgeschlagen') + } finally { + setUploading(false) + } + } + + const handleDelete = async (id) => { + if (!confirm('Dieses Foto löschen?')) return + try { + await api.deletePhoto(id) + if (big === id) setBig(null) + await load() + } catch (err) { + alert(err.message || 'Löschen fehlgeschlagen') + } + } + + const blocked = photoUsage && !photoUsage.allowed + + return ( +
+

+ Fotos +

+ +
+
+ Neues Foto + {photoUsage && } +
+

+ Fortschrittsfotos unabhängig von Umfängen. Pro Tag beliebig viele Aufnahmen möglich — Datum ist das + Zuordnungsdatum (z. B. Aufnahmezeitpunkt). +

+
+ + setDate(e.target.value)} + /> + +
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
Meine Fotos ({photos.length})
+ {!photos.length ? ( +
Noch keine Fotos.
+ ) : ( + <> + {big && ( +
setBig(null)} + role="presentation" + > + +
+ )} +
+ {photos.map((p) => ( +
+ setBig(p.id)} + /> +
+ {p.date?.slice(0, 10) || p.created?.slice(0, 10)} +
+ +
+ ))} +
+ + )} +
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index d716e9a..a96875d 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -171,14 +171,21 @@ export const api = { }, // Photos - uploadPhoto: (file,date='')=>{ - const fd=new FormData();fd.append('file',file);fd.append('date',date) - return fetch(`${BASE}/photos`,{method:'POST',body:fd,headers:hdrs()}).then(r=>r.json()) + uploadPhoto: async (file, date = '') => { + const fd = new FormData() + fd.append('file', file) + fd.append('date', date) + const r = await fetch(`${BASE}/photos`, { method: 'POST', body: fd, headers: hdrs() }) + const d = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d))) + return d }, - listPhotos: () => req('/photos'), - photoUrl: (pid) => { + listPhotos: () => req('/photos'), + deletePhoto: (id) => req(`/photos/${id}`, { method: 'DELETE' }), + /** Bild-URL; Query heißt `ssetoken` (require_auth_flexible), nicht `token`. */ + photoUrl: (pid) => { const token = getToken() - return `${BASE}/photos/${pid}${token ? `?token=${token}` : ''}` + return `${BASE}/photos/${pid}${token ? `?ssetoken=${encodeURIComponent(token)}` : ''}` }, // Nutrition