feat: enhance photo upload and management features
- Added `taken_at` timestamp to the photos table for improved photo metadata. - Updated the photo upload API to support optional EXIF data extraction and file last modified timestamp. - Enhanced the photo upload process to allow skipping EXIF data, defaulting to today's date if no other date is provided. - Improved the photo display in various components to utilize a unified caption format. - Refactored photo sorting and grouping logic for better organization in the UI.
This commit is contained in:
parent
c91317df8e
commit
0035d08149
4
backend/migrations/058_photos_taken_at.sql
Normal file
4
backend/migrations/058_photos_taken_at.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- EXIF-Aufnahmezeit (optional); Sortierung / Anzeige
|
||||||
|
ALTER TABLE photos ADD COLUMN IF NOT EXISTS taken_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN photos.taken_at IS 'Aufnahmezeit aus EXIF (DateTimeOriginal o.ä.), Zeitzone siehe PHOTO_EXIF_TIMEZONE';
|
||||||
103
backend/photo_exif.py
Normal file
103
backend/photo_exif.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""
|
||||||
|
EXIF-Aufnahmedatum/-zeit aus Bildbytes (JPEG, PNG mit EXIF, …).
|
||||||
|
|
||||||
|
EXIF enthält keine Zeitzone; wir interpretieren die Wandzeit in PHOTO_EXIF_TIMEZONE
|
||||||
|
(Standard Europe/Berlin) und speichern als TIMESTAMPTZ (UTC in PostgreSQL).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
EXIF_DATETIME_FMT = "%Y:%m:%d %H:%M:%S"
|
||||||
|
_EXIF_IFD = 0x8769
|
||||||
|
_EXIF_DATETIME_TAGS = (36867, 36868) # DateTimeOriginal, DateTimeDigitized
|
||||||
|
_TAG_DATETIME_MAIN = 306
|
||||||
|
|
||||||
|
|
||||||
|
def extract_taken_at_from_image_bytes(raw: bytes) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Liest DateTimeOriginal (o. ä.) aus EXIF und gibt ein timezone-aware datetime zurück,
|
||||||
|
oder None wenn nicht ermittelbar.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = Image.open(BytesIO(raw))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
naive = _extract_exif_naive_datetime(img)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
img.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if naive is None:
|
||||||
|
return None
|
||||||
|
tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin")
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Berlin")
|
||||||
|
return naive.replace(tzinfo=tz)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_exif_naive_datetime(img: Image.Image) -> Optional[datetime]:
|
||||||
|
exif = img.getexif()
|
||||||
|
if not exif:
|
||||||
|
return None
|
||||||
|
strings: list[str] = []
|
||||||
|
try:
|
||||||
|
exif_ifd = exif.get_ifd(_EXIF_IFD)
|
||||||
|
except Exception:
|
||||||
|
exif_ifd = None
|
||||||
|
if exif_ifd:
|
||||||
|
for tag in _EXIF_DATETIME_TAGS:
|
||||||
|
v = exif_ifd.get(tag)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
strings.append(v)
|
||||||
|
v = exif.get(_TAG_DATETIME_MAIN)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
strings.append(v)
|
||||||
|
for s in strings:
|
||||||
|
dt = _parse_exif_datetime_str(s)
|
||||||
|
if dt:
|
||||||
|
return dt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exif_datetime_str(s: str) -> Optional[datetime]:
|
||||||
|
s = (s or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, EXIF_DATETIME_FMT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def taken_at_from_file_last_modified_ms(ms_raw: Optional[str]) -> Optional[datetime]:
|
||||||
|
"""
|
||||||
|
Browser sendet File.lastModified (ms seit UTC-Epoch), echte Dateirevision auf der Platte.
|
||||||
|
Wird als echter Zeitpunkt interpretiert und nach PHOTO_EXIF_TIMEZONE für Anzeige gelegt
|
||||||
|
(konsistent zu EXIF-Wandzeit).
|
||||||
|
"""
|
||||||
|
if not ms_raw or not str(ms_raw).strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ms = int(str(ms_raw).strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if ms <= 0:
|
||||||
|
return None
|
||||||
|
instant_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
|
||||||
|
tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin")
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Berlin")
|
||||||
|
return instant_utc.astimezone(tz)
|
||||||
|
|
@ -9,3 +9,4 @@ bcrypt==4.1.3
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-dateutil==2.9.0
|
python-dateutil==2.9.0
|
||||||
|
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ Handles progress photo uploads and retrieval.
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date as date_cls
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ from fastapi.responses import FileResponse
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
|
from photo_exif import extract_taken_at_from_image_bytes, taken_at_from_file_last_modified_ms
|
||||||
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
|
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
|
|
@ -46,9 +48,20 @@ def resolve_photo_path(stored: Optional[str]) -> Optional[Path]:
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
|
async def upload_photo(
|
||||||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
file: UploadFile = File(...),
|
||||||
"""Upload progress photo."""
|
date: str = Form(""),
|
||||||
|
skip_exif: str = Form(""),
|
||||||
|
file_last_modified: str = Form(""),
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload progress photo.
|
||||||
|
|
||||||
|
Reihenfolge (wenn nicht ``skip_exif``): EXIF → Datei-Zeitstempel (Browser ``File.lastModified``)
|
||||||
|
→ Formularfeld ``date`` → heute. Bei ``skip_exif``: nur Formular / heute (weder EXIF noch Datei-Zeit).
|
||||||
|
"""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
# Phase 4: Check feature access and ENFORCE
|
||||||
|
|
@ -66,25 +79,53 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
raw = await file.read()
|
||||||
|
ignore_exif = skip_exif.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
taken_at = None
|
||||||
|
exif_used = False
|
||||||
|
file_mtime_used = False
|
||||||
|
|
||||||
|
if not ignore_exif:
|
||||||
|
taken_at = extract_taken_at_from_image_bytes(raw)
|
||||||
|
exif_used = taken_at is not None
|
||||||
|
if not taken_at:
|
||||||
|
taken_at = taken_at_from_file_last_modified_ms(file_last_modified)
|
||||||
|
file_mtime_used = taken_at is not None
|
||||||
|
|
||||||
|
if taken_at:
|
||||||
|
photo_date = taken_at.date()
|
||||||
|
elif date and date.strip():
|
||||||
|
photo_date = date_cls.fromisoformat(date.strip()[:10])
|
||||||
|
else:
|
||||||
|
photo_date = date_cls.today()
|
||||||
|
|
||||||
fid = str(uuid.uuid4())
|
fid = str(uuid.uuid4())
|
||||||
ext = Path(file.filename).suffix or '.jpg'
|
ext = Path(file.filename).suffix or ".jpg"
|
||||||
fname = f"{fid}{ext}"
|
fname = f"{fid}{ext}"
|
||||||
path = PHOTOS_DIR / fname
|
path = PHOTOS_DIR / fname
|
||||||
async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
|
async with aiofiles.open(path, "wb") as f:
|
||||||
|
await f.write(raw)
|
||||||
# Convert empty string to NULL for date field
|
|
||||||
photo_date = date if date and date.strip() else None
|
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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(
|
||||||
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
"""
|
||||||
(fid,pid,photo_date,fname))
|
INSERT INTO photos (id, profile_id, date, path, taken_at, created)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
""",
|
||||||
|
(fid, pid, photo_date, fname, taken_at),
|
||||||
|
)
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
# Phase 2: Increment usage counter
|
||||||
increment_feature_usage(pid, 'photos')
|
increment_feature_usage(pid, "photos")
|
||||||
|
|
||||||
return {"id":fid,"date":photo_date}
|
return {
|
||||||
|
"id": fid,
|
||||||
|
"date": photo_date.isoformat(),
|
||||||
|
"taken_at": taken_at.isoformat() if taken_at else None,
|
||||||
|
"exif_used": exif_used,
|
||||||
|
"file_mtime_used": file_mtime_used,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{fid}")
|
@router.get("/{fid}")
|
||||||
|
|
@ -114,7 +155,7 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM photos WHERE profile_id=%s
|
SELECT * FROM photos WHERE profile_id=%s
|
||||||
ORDER BY date DESC NULLS LAST, created DESC
|
ORDER BY COALESCE(taken_at, date::timestamptz, created) DESC NULLS LAST
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
""",
|
""",
|
||||||
(pid,),
|
(pid,),
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ CREATE TABLE IF NOT EXISTS photos (
|
||||||
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
|
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
|
||||||
date DATE,
|
date DATE,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
|
taken_at TIMESTAMPTZ, -- Aufnahmezeit aus EXIF (optional)
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../../utils/api'
|
import { api } from '../../utils/api'
|
||||||
|
import { formatPhotoCaption } from '../../utils/photoDisplay'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos).
|
* Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos).
|
||||||
|
|
@ -76,7 +77,7 @@ export default function ProgressPhotosWidget({ refreshTick = 0 }) {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
{formatPhotoCaption(p)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export default function CircumScreen() {
|
||||||
FIELDS.forEach(k=>{ if(form[k]!=='') payload[k]=parseFloat(form[k]) })
|
FIELDS.forEach(k=>{ if(form[k]!=='') payload[k]=parseFloat(form[k]) })
|
||||||
if(form.notes) payload.notes = form.notes
|
if(form.notes) payload.notes = form.notes
|
||||||
if(photoFile) {
|
if(photoFile) {
|
||||||
const pr = await api.uploadPhoto(photoFile, form.date)
|
const pr = await api.uploadPhoto(photoFile, form.date, { skipExif: true })
|
||||||
payload.photo_id = pr.id
|
payload.photo_id = pr.id
|
||||||
}
|
}
|
||||||
await api.upsertCirc(payload)
|
await api.upsertCirc(payload)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||||||
import { getBfCategory } from '../utils/calc'
|
import { getBfCategory } from '../utils/calc'
|
||||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
|
|
@ -897,15 +898,6 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Photo Grid ────────────────────────────────────────────────────────────────
|
// ── 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() {
|
function PhotoGrid() {
|
||||||
const [photos, setPhotos] = useState([])
|
const [photos, setPhotos] = useState([])
|
||||||
const [big, setBig] = useState(null)
|
const [big, setBig] = useState(null)
|
||||||
|
|
@ -931,10 +923,10 @@ function PhotoGrid() {
|
||||||
return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
|
return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...photos].sort((a, b) => sortPhotoDateKey(b).localeCompare(sortPhotoDateKey(a)))
|
const sorted = [...photos].sort((a, b) => photoSortKey(b).localeCompare(photoSortKey(a)))
|
||||||
const byMonth = new Map()
|
const byMonth = new Map()
|
||||||
for (const p of sorted) {
|
for (const p of sorted) {
|
||||||
const mk = monthKeyFromPhoto(p)
|
const mk = photoMonthKey(p)
|
||||||
if (!byMonth.has(mk)) byMonth.set(mk, [])
|
if (!byMonth.has(mk)) byMonth.set(mk, [])
|
||||||
byMonth.get(mk).push(p)
|
byMonth.get(mk).push(p)
|
||||||
}
|
}
|
||||||
|
|
@ -993,7 +985,7 @@ function PhotoGrid() {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
{formatPhotoCaption(p)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export default function NewMeasurement() {
|
||||||
if (id) { await api.updateMeasurement(id, payload) }
|
if (id) { await api.updateMeasurement(id, payload) }
|
||||||
else { const r = await api.createMeasurement(payload); mid = r.id }
|
else { const r = await api.createMeasurement(payload); mid = r.id }
|
||||||
if (photoFile && mid) {
|
if (photoFile && mid) {
|
||||||
const pr = await api.uploadPhoto(photoFile, form.date)
|
const pr = await api.uploadPhoto(photoFile, form.date, { skipExif: true })
|
||||||
await api.updateMeasurement(mid, {photo_id: pr.id})
|
await api.updateMeasurement(mid, {photo_id: pr.id})
|
||||||
}
|
}
|
||||||
setSaved(true); setTimeout(()=>nav('/'), 1200)
|
setSaved(true); setTimeout(()=>nav('/'), 1200)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Camera, Trash2 } from 'lucide-react'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import UsageBadge from '../components/UsageBadge'
|
import UsageBadge from '../components/UsageBadge'
|
||||||
|
import { formatPhotoCaption } from '../utils/photoDisplay'
|
||||||
|
|
||||||
export default function PhotosCapturePage() {
|
export default function PhotosCapturePage() {
|
||||||
const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD'))
|
const [date, setDate] = useState(() => dayjs().format('YYYY-MM-DD'))
|
||||||
|
|
@ -11,6 +12,7 @@ export default function PhotosCapturePage() {
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [big, setBig] = useState(null)
|
const [big, setBig] = useState(null)
|
||||||
|
const [skipExif, setSkipExif] = useState(false)
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
|
|
||||||
const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([]))
|
const load = () => api.listPhotos().then(setPhotos).catch(() => setPhotos([]))
|
||||||
|
|
@ -38,7 +40,7 @@ export default function PhotosCapturePage() {
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await api.uploadPhoto(file, date)
|
await api.uploadPhoto(file, date, { skipExif })
|
||||||
}
|
}
|
||||||
await load()
|
await load()
|
||||||
await loadUsage()
|
await loadUsage()
|
||||||
|
|
@ -74,11 +76,12 @@ export default function PhotosCapturePage() {
|
||||||
{photoUsage && <UsageBadge {...photoUsage} />}
|
{photoUsage && <UsageBadge {...photoUsage} />}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 0, marginBottom: 14, lineHeight: 1.5 }}>
|
<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
|
Reihenfolge: zuerst <strong>EXIF</strong> (Aufnahme aus dem Bild), sonst die{' '}
|
||||||
Zuordnungsdatum (z. B. Aufnahmezeitpunkt).
|
<strong>Datei-Zeit</strong> (Letzte Änderung der Datei, wie vom Browser übermittelt), sonst das
|
||||||
|
Fallback-Datum unten (Standard: heute).
|
||||||
</p>
|
</p>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Zuordnungsdatum</label>
|
<label className="form-label">Datum (Fallback ohne EXIF)</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
|
@ -88,6 +91,20 @@ export default function PhotosCapturePage() {
|
||||||
/>
|
/>
|
||||||
<span className="form-unit" />
|
<span className="form-unit" />
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
marginBottom: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={skipExif} onChange={(e) => setSkipExif(e.target.checked)} />
|
||||||
|
EXIF und Datei-Zeit ignorieren — nur das Fallback-Datum verwenden
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -171,7 +188,7 @@ export default function PhotosCapturePage() {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
{formatPhotoCaption(p)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -170,11 +170,15 @@ export const api = {
|
||||||
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||||
},
|
},
|
||||||
|
|
||||||
// Photos
|
// Photos: EXIF → File.lastModified → date-Feld → heute; skipExif nur Formular/heute
|
||||||
uploadPhoto: async (file, date = '') => {
|
uploadPhoto: async (file, date = '', opts = {}) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
fd.append('date', date)
|
fd.append('date', date)
|
||||||
|
if (opts.skipExif) fd.append('skip_exif', '1')
|
||||||
|
if (file && typeof file.lastModified === 'number' && !Number.isNaN(file.lastModified) && file.lastModified > 0) {
|
||||||
|
fd.append('file_last_modified', String(file.lastModified))
|
||||||
|
}
|
||||||
const r = await fetch(`${BASE}/photos`, { method: 'POST', body: fd, headers: hdrs() })
|
const r = await fetch(`${BASE}/photos`, { method: 'POST', body: fd, headers: hdrs() })
|
||||||
const d = await r.json().catch(() => ({}))
|
const d = await r.json().catch(() => ({}))
|
||||||
if (!r.ok) throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)))
|
if (!r.ok) throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)))
|
||||||
|
|
|
||||||
25
frontend/src/utils/photoDisplay.js
Normal file
25
frontend/src/utils/photoDisplay.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
/** Monatsschlüssel YYYY-MM für Gruppierung (EXIF-Zeit bevorzugt). */
|
||||||
|
export function photoMonthKey(p) {
|
||||||
|
if (p.taken_at) return p.taken_at.slice(0, 7)
|
||||||
|
if (p.date) {
|
||||||
|
return typeof p.date === 'string' ? p.date.slice(0, 7) : dayjs(p.date).format('YYYY-MM')
|
||||||
|
}
|
||||||
|
return (p.created || '').slice(0, 7) || '0000-00'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sortierung: ISO-Zeit oder Datum. */
|
||||||
|
export function photoSortKey(p) {
|
||||||
|
if (p.taken_at) return p.taken_at
|
||||||
|
const d = p.date || p.created || ''
|
||||||
|
return typeof d === 'string' ? d.slice(0, 10) : dayjs(d).format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge / Untertitel: mit Uhrzeit wenn EXIF vorhanden. */
|
||||||
|
export function formatPhotoCaption(p) {
|
||||||
|
if (p.taken_at) return dayjs(p.taken_at).format('DD.MM.YYYY HH:mm')
|
||||||
|
const raw = p.date || p.created
|
||||||
|
if (!raw) return ''
|
||||||
|
return typeof raw === 'string' ? raw.slice(0, 10) : dayjs(raw).format('DD.MM.YYYY')
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user