Add web upload: drag-and-drop images to depot via browser
Build & Deploy / build-and-deploy (push) Successful in 37s
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:
@@ -156,6 +156,43 @@ async def get_results_endpoint(db=Depends(get_connection)):
|
|||||||
return await get_results(db)
|
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
|
# Bilder
|
||||||
# =====================
|
# =====================
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import StartlistPage from './pages/StartlistPage.jsx'
|
|||||||
import ReviewPage from './pages/ReviewPage.jsx'
|
import ReviewPage from './pages/ReviewPage.jsx'
|
||||||
import ResultsPage from './pages/ResultsPage.jsx'
|
import ResultsPage from './pages/ResultsPage.jsx'
|
||||||
import PassagesPage from './pages/PassagesPage.jsx'
|
import PassagesPage from './pages/PassagesPage.jsx'
|
||||||
|
import UploadPage from './pages/UploadPage.jsx'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
@@ -14,6 +15,7 @@ function Nav() {
|
|||||||
<NavLink to="/passages" className={linkClass}>Passeringer</NavLink>
|
<NavLink to="/passages" className={linkClass}>Passeringer</NavLink>
|
||||||
<NavLink to="/review" className={linkClass}>Gjennomgang</NavLink>
|
<NavLink to="/review" className={linkClass}>Gjennomgang</NavLink>
|
||||||
<NavLink to="/results" className={linkClass}>Resultater</NavLink>
|
<NavLink to="/results" className={linkClass}>Resultater</NavLink>
|
||||||
|
<NavLink to="/upload" className={linkClass}>Last opp</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,7 @@ export default function App() {
|
|||||||
<Route path="/passages" element={<PassagesPage />} />
|
<Route path="/passages" element={<PassagesPage />} />
|
||||||
<Route path="/review" element={<ReviewPage />} />
|
<Route path="/review" element={<ReviewPage />} />
|
||||||
<Route path="/results" element={<ResultsPage />} />
|
<Route path="/results" element={<ResultsPage />} />
|
||||||
|
<Route path="/upload" element={<UploadPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 nå 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user