Add web upload: drag-and-drop images to depot via browser
Build & Deploy / build-and-deploy (push) Successful in 37s

- POST /api/upload saves files to /depot/ for ingest processing
- Batches of 10 files per request
- Drag-and-drop zone + file picker, per-file status feedback
- New 'Last opp'-tab in navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:33:27 +01:00
parent f756ec5412
commit 82e1124f9f
3 changed files with 215 additions and 0 deletions
+37
View File
@@ -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
# =====================
+3
View File
@@ -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() {
<NavLink to="/passages" className={linkClass}>Passeringer</NavLink>
<NavLink to="/review" className={linkClass}>Gjennomgang</NavLink>
<NavLink to="/results" className={linkClass}>Resultater</NavLink>
<NavLink to="/upload" className={linkClass}>Last opp</NavLink>
</nav>
)
}
@@ -28,6 +30,7 @@ export default function App() {
<Route path="/passages" element={<PassagesPage />} />
<Route path="/review" element={<ReviewPage />} />
<Route path="/results" element={<ResultsPage />} />
<Route path="/upload" element={<UploadPage />} />
</Routes>
</main>
</>
+175
View File
@@ -0,0 +1,175 @@
import { useRef, useState } from 'react'
const ACCEPTED = '.jpg,.jpeg,.png'
function FileRow({ file, result }) {
const status = result
? result.ok
? <span className="badge badge-success">OK {result.saved_as}</span>
: <span className="badge badge-danger">Feil: {result.error}</span>
: <span className="badge badge-warning">Venter...</span>
return (
<tr>
<td>{file.name}</td>
<td>{(file.size / 1024).toFixed(0)} KB</td>
<td>{status}</td>
</tr>
)
}
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 (
<>
<h1>Last opp bilder</h1>
<div className="card">
<h2>Velg bilder</h2>
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }}>
JPEG og PNG støttes. Bildene legges i depot og behandles automatisk (EXIF valideres, OCR kjøres).
</p>
{/* Dra-og-slipp-sone */}
<div
ref={dropRef}
onDrop={handleDrop}
onDragOver={e => { 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
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED}
multiple
style={{ display: 'none' }}
onChange={e => addFiles(e.target.files)}
/>
{files.length > 0 && (
<div className="form-row" style={{ marginTop: '0.5rem' }}>
<button
className="btn-primary"
onClick={handleUpload}
disabled={uploading}
>
{uploading ? `Laster opp... (${Object.keys(results).length}/${files.length})` : `Last opp ${files.length} bilde(r)`}
</button>
<button className="btn-danger btn-sm" onClick={handleClear} disabled={uploading}>
Nullstill
</button>
</div>
)}
</div>
{summary && (
<div className={`alert alert-${summary.ok === summary.total ? 'success' : 'info'}`}>
{summary.ok} av {summary.total} bilder lastet opp. Behandles i bakgrunnen.
</div>
)}
{files.length > 0 && (
<div className="card">
<h2>Filer ({files.length})</h2>
<table>
<thead>
<tr>
<th>Filnavn</th>
<th>Størrelse</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{files.map(f => (
<FileRow key={f.name} file={f} result={results[f.name]} />
))}
</tbody>
</table>
</div>
)}
<style>{`
.drag-over {
border-color: #1a1a2e !important;
background: #f0f0f8;
}
`}</style>
</>
)
}