Merge pull request 'feat: implement photo management features in the application' (#89) from develop into main
Reviewed-on: #89
This commit is contained in:
commit
9720cb57ec
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <img> tags)."""
|
||||
"""Get photo by ID. Auth via header or query param ssetoken (for <img> 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}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/wizard" element={<MeasureWizard />} />
|
||||
<Route path="/weight" element={<WeightScreen />} />
|
||||
<Route path="/circum" element={<CircumScreen />} />
|
||||
<Route path="/photos" element={<PhotosCapturePage />} />
|
||||
<Route path="/caliper" element={<CaliperScreen />} />
|
||||
<Route path="/sleep" element={<SleepPage />} />
|
||||
<Route path="/rest-days" element={<RestDaysPage />} />
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export default function ProgressPhotosWidget({ refreshTick = 0 }) {
|
|||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16, textAlign: 'center', padding: 24 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginBottom: 12 }}>Noch keine Fotos.</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => nav('/capture')}>
|
||||
Zur Erfassung
|
||||
<button type="button" className="btn btn-primary" onClick={() => nav('/photos')}>
|
||||
Fotos erfassen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 <EmptySection text="Noch keine Fotos." to="/circum" toLabel="Umfänge erfassen"/>
|
||||
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 <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
|
||||
}
|
||||
|
||||
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&&<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.9)',zIndex:100,
|
||||
display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>setBig(null)}>
|
||||
<img src={api.photoUrl(big)} style={{maxWidth:'100%',maxHeight:'100%',borderRadius:8}} alt=""/>
|
||||
</div>}
|
||||
<div className="photo-grid">
|
||||
{photos.map(p=>(
|
||||
<div key={p.id} style={{position:'relative'}}>
|
||||
<img src={api.photoUrl(p.id)} className="photo-thumb" onClick={()=>setBig(p.id)} alt=""/>
|
||||
<div style={{position:'absolute',bottom:4,left:4,fontSize:9,background:'rgba(0,0,0,0.6)',
|
||||
color:'white',padding:'1px 4px',borderRadius:3}}>
|
||||
{p.date?.slice(0,10)||p.created?.slice(0,10)}
|
||||
</div>
|
||||
{big && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.9)',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => setBig(null)}
|
||||
role="presentation"
|
||||
>
|
||||
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
|
||||
</div>
|
||||
)}
|
||||
{monthKeys.map((mk) => (
|
||||
<div key={mk} style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text3)',
|
||||
marginBottom: 10,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{dayjs(`${mk}-01`).format('MMMM YYYY')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="photo-grid">
|
||||
{byMonth.get(mk).map((p) => (
|
||||
<div key={p.id} style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={api.photoUrl(p.id)}
|
||||
className="photo-thumb"
|
||||
onClick={() => setBig(p.id)}
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
fontSize: 9,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
padding: '1px 4px',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
padding: 6,
|
||||
minWidth: 0,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
title="Löschen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(p.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
203
frontend/src/pages/PhotosCapturePage.jsx
Normal file
203
frontend/src/pages/PhotosCapturePage.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="capture-page">
|
||||
<h1 className="page-title" style={{ marginBottom: 16 }}>
|
||||
Fotos
|
||||
</h1>
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title badge-container-right">
|
||||
<span>Neues Foto</span>
|
||||
{photoUsage && <UsageBadge {...photoUsage} />}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 0, marginBottom: 14, lineHeight: 1.5 }}>
|
||||
Fortschrittsfotos unabhängig von Umfängen. Pro Tag beliebig viele Aufnahmen möglich — Datum ist das
|
||||
Zuordnungsdatum (z. B. Aufnahmezeitpunkt).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Zuordnungsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
style={{ width: '100%', maxWidth: 220 }}
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={onPickFiles}
|
||||
/>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: 'var(--danger-bg)',
|
||||
border: '1px solid var(--danger)',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--danger)',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-full"
|
||||
disabled={uploading || blocked}
|
||||
title={
|
||||
blocked
|
||||
? `Limit erreicht (${photoUsage.used}/${photoUsage.limit}).`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Camera size={16} style={{ marginRight: 8 }} />
|
||||
{uploading ? 'Wird hochgeladen…' : 'Foto(s) auswählen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Meine Fotos ({photos.length})</div>
|
||||
{!photos.length ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', padding: '8px 0' }}>Noch keine Fotos.</div>
|
||||
) : (
|
||||
<>
|
||||
{big && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.9)',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => setBig(null)}
|
||||
role="presentation"
|
||||
>
|
||||
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
|
||||
</div>
|
||||
)}
|
||||
<div className="photo-grid">
|
||||
{photos.map((p) => (
|
||||
<div key={p.id} style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={api.photoUrl(p.id)}
|
||||
className="photo-thumb"
|
||||
alt=""
|
||||
onClick={() => setBig(p.id)}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
fontSize: 9,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
padding: '1px 4px',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
padding: 6,
|
||||
minWidth: 0,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
title="Löschen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(p.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user