inou/portal/templates/upload.tmpl

268 lines
10 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">
{{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}}
<span class="status-expires">Expires {{.ExpiresAt}}</span>
<button type="button" class="btn-icon" title="Delete" onclick="deleteFile({{.ID}})">×</button>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="mt-16">
<button type="button" class="btn btn-primary" id="process-imaging-btn" onclick="processImaging()">Process Imaging Uploads</button>
</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 id="progress-text">Uploading...</p>
<div class="progress-bar-wrap">
<div class="progress-bar" id="progress-bar"></div>
</div>
<p class="progress-detail" id="progress-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 bar = document.getElementById('progress-bar');
const text = document.getElementById('progress-text');
const detail = document.getElementById('progress-detail');
const categorySelect = document.getElementById('category-select');
const dossierGUID = '{{.TargetDossier.DossierID}}';
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) {
const files = await getAllFiles(items);
if (files.length > 0) handleFiles(files);
}
};
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 handleFiles(files) {
if (!categorySelect.value) { alert('Please select a category first'); return; }
if (!files.length) return;
const category = categorySelect.value;
overlay.style.display = 'flex';
bar.style.width = '0%';
let geneticsFileId = null;
for (let i = 0; i < files.length; i++) {
const file = files[i];
text.textContent = 'Uploading ' + (i + 1) + ' / ' + files.length;
detail.textContent = file.relativePath || file.webkitRelativePath || file.name;
const form = new FormData();
form.append('file', file);
form.append('path', file.relativePath || file.webkitRelativePath || file.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;
}
} catch (e) {
console.error('Upload failed:', e);
}
bar.style.width = ((i + 1) / files.length * 100) + '%';
}
if (geneticsFileId) {
text.textContent = 'Processing genetics data...';
detail.textContent = 'Analyzing genetic variants...';
bar.style.width = '100%';
await pollStatus(geneticsFileId);
}
overlay.style.display = 'none';
location.reload();
}
async function pollStatus(fileId) {
const maxAttempts = 60; // 60 seconds max
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') {
showToast('Genetics data processed successfully!', '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));
}
async function processImaging() {
const btn = document.getElementById('process-imaging-btn');
btn.disabled = true;
btn.textContent = 'Processing (may take minutes)...';
overlay.style.display = 'flex';
text.textContent = 'Decrypting and importing... please wait';
detail.textContent = 'This can take several minutes for large uploads';
bar.style.width = '50%';
try {
const resp = await fetch('/dossier/' + dossierGUID + '/process-imaging', { method: 'POST' });
const data = await resp.json();
if (data.status === 'ok') {
text.textContent = data.message;
bar.style.width = '100%';
showToast(data.message, 'success');
setTimeout(() => location.reload(), 2000);
} else {
showToast(data.message || 'Processing failed', 'error');
overlay.style.display = 'none';
btn.disabled = false;
btn.textContent = 'Process Imaging Uploads';
}
} catch (e) {
showToast('Processing failed: ' + e, 'error');
overlay.style.display = 'none';
btn.disabled = false;
btn.textContent = 'Process Imaging Uploads';
}
}
</script>
{{end}}