diff --git a/backend/api.py b/backend/api.py index f3d9bd6..fad4a40 100644 --- a/backend/api.py +++ b/backend/api.py @@ -156,6 +156,43 @@ async def get_results_endpoint(db=Depends(get_connection)): return await get_results(db) +# ===================== +# Bildeopplasting +# ===================== + +VALID_IMAGE_TYPES = {"image/jpeg", "image/png"} +VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"} + + +@app.post("/api/upload", summary="Last opp ett eller flere bilder til depot") +async def upload_images(files: list[UploadFile] = File(...)): + """ + Lagrer opplastede bilder i /depot/ slik at ingest-prosessen plukker dem opp. + Returnerer status per fil. + """ + results = [] + for file in files: + suffix = Path(file.filename).suffix.lower() + if suffix not in VALID_IMAGE_SUFFIXES: + results.append({"filename": file.filename, "ok": False, "error": "Ugyldig filtype"}) + continue + + dest = Path("/depot") / file.filename + # Unngå overskrivning ved navnekollisjon + if dest.exists(): + import uuid as _uuid + dest = Path("/depot") / f"{_uuid.uuid4().hex}_{file.filename}" + + try: + content = await file.read() + dest.write_bytes(content) + results.append({"filename": file.filename, "ok": True, "saved_as": dest.name}) + except Exception as e: + results.append({"filename": file.filename, "ok": False, "error": str(e)}) + + return {"uploaded": sum(1 for r in results if r["ok"]), "results": results} + + # ===================== # Bilder # ===================== diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9257ac1..1b249b8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import StartlistPage from './pages/StartlistPage.jsx' import ReviewPage from './pages/ReviewPage.jsx' import ResultsPage from './pages/ResultsPage.jsx' import PassagesPage from './pages/PassagesPage.jsx' +import UploadPage from './pages/UploadPage.jsx' import './App.css' function Nav() { @@ -14,6 +15,7 @@ function Nav() { Passeringer Gjennomgang Resultater + Last opp ) } @@ -28,6 +30,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx new file mode 100644 index 0000000..cd0d7b6 --- /dev/null +++ b/frontend/src/pages/UploadPage.jsx @@ -0,0 +1,175 @@ +import { useRef, useState } from 'react' + +const ACCEPTED = '.jpg,.jpeg,.png' + +function FileRow({ file, result }) { + const status = result + ? result.ok + ? OK — {result.saved_as} + : Feil: {result.error} + : Venter... + + return ( + + {file.name} + {(file.size / 1024).toFixed(0)} KB + {status} + + ) +} + +export default function UploadPage() { + const [files, setFiles] = useState([]) + const [results, setResults] = useState({}) + const [uploading, setUploading] = useState(false) + const [summary, setSummary] = useState(null) + const inputRef = useRef() + const dropRef = useRef() + + const addFiles = (newFiles) => { + const valid = Array.from(newFiles).filter(f => + f.type === 'image/jpeg' || f.type === 'image/png' + ) + setFiles(prev => { + const names = new Set(prev.map(f => f.name)) + return [...prev, ...valid.filter(f => !names.has(f.name))] + }) + } + + const handleDrop = (e) => { + e.preventDefault() + dropRef.current.classList.remove('drag-over') + addFiles(e.dataTransfer.files) + } + + const handleUpload = async () => { + if (files.length === 0) return + setUploading(true) + setResults({}) + setSummary(null) + + // Send i bolker på 10 for å unngå for store requests + const BATCH = 10 + const allResults = {} + + for (let i = 0; i < files.length; i += BATCH) { + const batch = files.slice(i, i + BATCH) + const form = new FormData() + batch.forEach(f => form.append('files', f)) + + try { + const res = await fetch('/api/upload', { method: 'POST', body: form }) + const data = await res.json() + data.results.forEach((r, j) => { + allResults[batch[j].name] = r + }) + setResults({ ...allResults }) + } catch (err) { + batch.forEach(f => { allResults[f.name] = { ok: false, error: err.message } }) + setResults({ ...allResults }) + } + } + + const ok = Object.values(allResults).filter(r => r.ok).length + setSummary({ ok, total: files.length }) + setUploading(false) + } + + const handleClear = () => { + setFiles([]) + setResults({}) + setSummary(null) + inputRef.current.value = '' + } + + return ( + <> +

Last opp bilder

+ +
+

Velg bilder

+

+ JPEG og PNG støttes. Bildene legges i depot og behandles automatisk (EXIF valideres, OCR kjøres). +

+ + {/* Dra-og-slipp-sone */} +
{ e.preventDefault(); dropRef.current.classList.add('drag-over') }} + onDragLeave={() => dropRef.current.classList.remove('drag-over')} + onClick={() => inputRef.current.click()} + style={{ + border: '2px dashed #ddd', + borderRadius: 8, + padding: '2rem', + textAlign: 'center', + cursor: 'pointer', + color: '#aaa', + marginBottom: '0.75rem', + transition: 'border-color 0.15s, background 0.15s', + }} + > + Dra og slipp bilder hit, eller klikk for å velge +
+ + addFiles(e.target.files)} + /> + + {files.length > 0 && ( +
+ + +
+ )} +
+ + {summary && ( +
+ {summary.ok} av {summary.total} bilder lastet opp. Behandles nå i bakgrunnen. +
+ )} + + {files.length > 0 && ( +
+

Filer ({files.length})

+ + + + + + + + + + {files.map(f => ( + + ))} + +
FilnavnStørrelseStatus
+
+ )} + + + + ) +}