inou/portal/templates/upload.tmpl

355 lines
14 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "upload"}}
<div class="container">
<p class="text-muted text-small mb-24"><a href="/dossier/{{.TargetDossier.DossierID}}">← Back to {{.TargetDossier.Name}}</a></p>
<h1 class="small">Upload health data</h1>
<p class="text-muted text-small mb-24">Files are automatically deleted after 7 days</p>
<div class="form-group mb-16">
<label class="form-label">Category</label>
<select id="category-select" class="form-select" required>
<option value="" selected disabled>-- Select category --</option>
<option value="imaging">Medical Imaging (DICOM, X-ray, MRI, CT)</option>
<option value="labs">Lab Results (Quest, LabCorp, bloodwork)</option>
<option value="genetics">Genetics (VCF, AncestryDNA, 23andMe)</option>
<option value="vitals">Vitals (pulse ox, BP, device exports)</option>
<option value="documents">Documents (clinical notes, reports)</option>
<option value="other">Other</option>
</select>
</div>
<div class="upload-area" id="upload-area">
<div class="upload-icon">
<svg width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<p class="upload-text">Click or drag files/folders here</p>
<p class="upload-hint">DICOM folders, PDF, CSV, VCF, and more</p>
</div>
<input type="file" id="file-input" multiple webkitdirectory style="display:none">
<input type="file" id="file-input-files" multiple style="display:none">
<p class="text-muted text-small mt-24 mb-8">Or paste lab results, markdown tables, or other health data:</p>
<div class="paste-section">
<textarea id="paste-input" class="form-textarea" rows="5" placeholder="Paste health data here..."></textarea>
<button type="button" class="btn btn-sm mt-8" id="paste-submit" onclick="submitPaste()">Submit</button>
</div>
{{if .UploadList}}
<p class="section-label mt-24">Recent uploads</p>
<div class="file-table">
{{range .UploadList}}
<div class="file-row {{if .Deleted}}file-deleted{{end}}">
<div class="file-info">
<span class="file-name">{{.FileName}}</span>
<span class="file-meta">
{{if and (not .Deleted) (eq .Status "uploaded")}}
<select class="category-inline" onchange="updateCategory({{.ID}}, this.value)">
<option value="imaging" {{if eq .Category "imaging"}}selected{{end}}>imaging</option>
<option value="labs" {{if eq .Category "labs"}}selected{{end}}>labs</option>
<option value="genetics" {{if eq .Category "genetics"}}selected{{end}}>genetics</option>
<option value="vitals" {{if eq .Category "vitals"}}selected{{end}}>vitals</option>
<option value="documents" {{if eq .Category "documents"}}selected{{end}}>documents</option>
<option value="other" {{if eq .Category "other"}}selected{{end}}>other</option>
</select>
{{else}}
{{.Category}}
{{end}}
· {{.SizeHuman}} · {{.UploadedAt}}
</span>
</div>
<div class="file-status">
{{if .Deleted}}
<span class="status-deleted">{{.DeletedReason}}</span>
{{else}}
{{if ne .Status "uploaded"}}<span class="status-badge">{{.Status}}</span>{{end}}
{{if .CanUndo}}<button type="button" class="btn-icon" title="Undo import" onclick="undoImport({{.ID}})">↩</button>{{end}}
<span class="status-expires">Expires {{.ExpiresAt}}</span>
<button type="button" class="btn-icon" title="Delete" onclick="deleteFile({{.ID}})">×</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state mt-24">
No files uploaded yet
</div>
{{end}}
</div>
<div class="progress-overlay" id="progress-overlay">
<div class="progress-modal">
<p class="progress-label">Importing</p>
<p id="upload-text"></p>
<div class="progress-bar-wrap">
<div class="progress-bar" id="upload-bar"></div>
</div>
<p class="progress-detail" id="upload-detail"></p>
<p class="progress-label" id="process-label" style="margin-top:16px;display:none">Processing</p>
<div class="progress-track" id="process-track" style="display:none">
<div class="progress-bar" id="process-bar"></div>
</div>
<p id="process-text"></p>
<p class="progress-detail" id="process-detail"></p>
</div>
</div>
<script>
const area = document.getElementById('upload-area');
const inputFolder = document.getElementById('file-input');
const inputFiles = document.getElementById('file-input-files');
const overlay = document.getElementById('progress-overlay');
const uploadBar = document.getElementById('upload-bar');
const uploadText = document.getElementById('upload-text');
const uploadDetail = document.getElementById('upload-detail');
const processText = document.getElementById('process-text');
const processDetail = document.getElementById('process-detail');
const processLabel = document.getElementById('process-label');
const processBar = document.getElementById('process-bar');
const processTrack = document.getElementById('process-track');
const categorySelect = document.getElementById('category-select');
const dossierGUID = '{{.TargetDossier.DossierID}}';
let totalFileCount = 0;
area.onclick = e => {
if (e.shiftKey) { inputFolder.click(); } else { inputFiles.click(); }
};
area.ondragover = e => { e.preventDefault(); area.classList.add('dragover'); };
area.ondragleave = () => area.classList.remove('dragover');
area.ondrop = async e => {
e.preventDefault();
area.classList.remove('dragover');
const items = e.dataTransfer.items;
if (items && items.length > 0) {
overlay.style.display = 'flex';
uploadBar.style.width = '0%';
uploadText.textContent = 'Reading files...';
uploadDetail.textContent = '';
processText.textContent = '';
processDetail.textContent = '';
processLabel.style.display = 'none';
const files = await getAllFiles(items);
if (files.length > 0) handleFiles(files);
else overlay.style.display = 'none';
}
};
inputFolder.onchange = e => handleFiles(Array.from(e.target.files));
inputFiles.onchange = e => handleFiles(Array.from(e.target.files));
async function getAllFiles(items) {
const files = [];
const entries = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
if (entry) entries.push(entry);
}
async function readEntry(entry, path) {
if (entry.isFile) {
return new Promise(resolve => {
entry.file(f => {
Object.defineProperty(f, 'relativePath', { value: path + f.name });
files.push(f);
resolve();
});
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const readAll = () => new Promise(resolve => {
reader.readEntries(async entries => {
if (entries.length === 0) { resolve(); return; }
for (const e of entries) await readEntry(e, path + entry.name + '/');
await readAll();
resolve();
});
});
await readAll();
}
}
for (const entry of entries) await readEntry(entry, '');
return files;
}
async function submitPaste() {
const text = document.getElementById('paste-input').value.trim();
if (!text) return;
const blob = new Blob([text], { type: 'text/plain' });
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const file = new File([blob], 'paste-' + ts + '.txt', { type: 'text/plain' });
handleFiles([file]);
}
async function handleFiles(files) {
if (!files.length) return;
const category = categorySelect.value || '';
overlay.style.display = 'flex';
uploadBar.style.width = '0%';
uploadText.textContent = '';
processText.textContent = '';
processDetail.textContent = '';
processLabel.style.display = 'none';
let geneticsFileId = null;
let pdfFileIds = [];
totalFileCount = files.length;
let completed = 0;
const concurrency = 10;
async function uploadOne(file) {
const name = file.relativePath || file.webkitRelativePath || file.name;
const form = new FormData();
form.append('file', file);
form.append('path', name);
form.append('category', category);
try {
const resp = await fetch('/dossier/' + dossierGUID + '/upload', { method: 'POST', body: form });
const data = await resp.json();
if (category === 'genetics' && data.id) geneticsFileId = data.id;
if (category === 'pdf' && data.id) pdfFileIds.push(data.id);
} catch (e) { console.error('Upload failed:', e); }
completed++;
uploadText.textContent = completed + ' / ' + files.length;
uploadBar.style.width = (completed / files.length * 100) + '%';
}
// Upload with concurrency limit
const queue = [...files];
const workers = [];
for (let w = 0; w < Math.min(concurrency, queue.length); w++) {
workers.push((async () => {
while (queue.length > 0) {
await uploadOne(queue.shift());
}
})());
}
await Promise.all(workers);
if (geneticsFileId) {
processText.textContent = 'Processing genetics...';
processDetail.textContent = 'Analyzing genetic variants...';
await pollStatus(geneticsFileId);
overlay.style.display = 'none';
location.reload();
return;
}
// PDF: poll for processing completion
if (pdfFileIds.length > 0) {
processText.textContent = 'Processing documents...';
processDetail.textContent = 'OCR + extracting data...';
processLabel.style.display = '';
await Promise.all(pdfFileIds.map(id => pollStatus(id)));
overlay.style.display = 'none';
location.reload();
return;
}
// Process all uploaded files in one batch (DICOM only)
if (category !== 'genetics') {
fetch('/dossier/' + dossierGUID + '/process-imaging', { method: 'POST' });
await pollProcessing();
}
overlay.style.display = 'none';
location.reload();
}
async function pollStatus(fileId) {
const maxAttempts = 60;
for (let i = 0; i < maxAttempts; i++) {
try {
const resp = await fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/status');
const data = await resp.json();
if (data.status === 'completed' || data.status === 'processed') {
showToast('Processing complete!', 'success');
return;
} else if (data.status === 'failed') {
showToast('Processing failed: ' + data.details, 'error');
return;
}
} catch (e) {
console.error('Status check failed:', e);
}
await new Promise(r => setTimeout(r, 1000));
}
showToast('Processing timed out', 'error');
}
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:16px 24px;border-radius:8px;color:white;font-weight:500;z-index:9999;animation:slideIn 0.3s ease';
toast.style.background = type === 'success' ? '#10b981' : '#ef4444';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
function updateCategory(fileId, newCategory) {
const form = new FormData();
form.append('category', newCategory);
fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/update', { method: 'POST', body: form })
.then(() => {})
.catch(e => console.error('Update failed:', e));
}
function deleteFile(fileId) {
if (!confirm('Delete this file?')) return;
fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/delete', { method: 'POST' })
.then(() => location.reload())
.catch(e => console.error('Delete failed:', e));
}
function undoImport(fileId) {
if (!confirm('Undo this import? All imported entries will be deleted.')) return;
fetch('/dossier/' + dossierGUID + '/files/' + fileId + '/undo', { method: 'POST' })
.then(resp => resp.json())
.then(data => {
if (data.status === 'ok') showToast('Import undone', 'success');
else showToast('Undo failed', 'error');
location.reload();
})
.catch(e => { console.error('Undo failed:', e); showToast('Undo failed', 'error'); });
}
async function pollProcessing() {
while (true) {
await new Promise(r => setTimeout(r, 300));
let s;
try {
const resp = await fetch('/dossier/' + dossierGUID + '/process-status');
s = await resp.json();
} catch (e) { continue; }
if (s.stage === 'idle' || s.stage === 'starting') continue;
processLabel.style.display = '';
processTrack.style.display = '';
const total = totalFileCount || s.total || 1;
if (s.stage === 'decrypting') {
processText.textContent = 'Preparing ' + (s.processed || 0) + ' / ' + total;
processDetail.textContent = '';
processBar.style.width = ((s.processed || 0) / total * 100) + '%';
} else if (s.stage === 'importing') {
processText.textContent = 'Importing ' + (s.processed || 0) + ' / ' + total;
const study = (s.study || '').replace(/&amp;/g, '&').replace(/&quot;/g, '"').trim();
const series = (s.series || '').replace(/&amp;/g, '&').replace(/&quot;/g, '"').trim();
processDetail.textContent = study + ' — ' + series;
processBar.style.width = ((s.processed || 0) / total * 100) + '%';
} else if (s.stage === 'importing_json') {
processText.textContent = 'Analyzing ' + (s.processed || 0) + ' / ' + (s.total || total);
processDetail.textContent = 'Extracting lab results...';
processBar.style.width = ((s.processed || 0) / (s.total || total) * 100) + '%';
} else if (s.stage === 'normalizing') {
const nt = s.total || 1;
processText.textContent = 'Normalizing ' + (s.processed || 0) + ' / ' + nt + ' test names';
processDetail.textContent = 'Mapping to LOINC codes...';
processBar.style.width = ((s.processed || 0) / nt * 100) + '%';
} else if (s.stage === 'done') {
if (s.error) showToast('Import error: ' + s.error, 'error');
else if (s.slices > 0) showToast('Imported ' + s.studies + ' studies, ' + s.series_count + ' series, ' + s.slices + ' slices', 'success');
else if (s.json_imported > 0) showToast('Imported ' + s.json_imported + ' files', 'success');
return;
}
}
}
</script>
{{end}}