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:
@@ -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