188 lines
7.3 KiB
Cheetah
188 lines
7.3 KiB
Cheetah
{{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 here</p>
|
||
<p class="upload-hint">DICOM, PDF, CSV, VCF, and more</p>
|
||
</div>
|
||
<input type="file" id="file-input" 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>
|
||
{{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 input = document.getElementById('file-input');
|
||
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 = () => input.click();
|
||
area.ondragover = e => { e.preventDefault(); area.classList.add('dragover'); };
|
||
area.ondragleave = () => area.classList.remove('dragover');
|
||
area.ondrop = e => { e.preventDefault(); area.classList.remove('dragover'); handleFiles(e.dataTransfer.files); };
|
||
input.onchange = e => handleFiles(e.target.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.name;
|
||
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
form.append('path', 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));
|
||
}
|
||
</script>
|
||
{{end}}
|