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})
+
+
+
+ | Filnavn |
+ Størrelse |
+ Status |
+
+
+
+ {files.map(f => (
+
+ ))}
+
+
+
+ )}
+
+
+ >
+ )
+}