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)}
+
+
{
+ e.stopPropagation()
+ handleDelete(p.id)
+ }}
+ >
+
+
+
+ ))}
+
+
+ ))}
>
)
}
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}
+
+ )}
+
fileRef.current?.click()}
+ >
+
+ {uploading ? 'Wird hochgeladen…' : 'Foto(s) auswählen'}
+
+
+
+
+
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)}
+
+
{
+ e.stopPropagation()
+ handleDelete(p.id)
+ }}
+ >
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
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