pig.py/templates/import.html.j2
Russell Ballestrini f124828c16 21 side quests scattered through the codebase
1-20 are findable. 21 is the absence itself.

fnord
2026-01-05 21:10:02 -05:00

219 lines
7.9 KiB
Django/Jinja

{% extends "base.html.j2" %}
<!-- Side quest 18/21: Import your past. Export your future. -->
{% block title %}Import - neopig{% endblock %}
{% block extra_css %}
/* Page layout */
.container { max-width: 800px; margin: 0 auto; }
.subtitle { margin-bottom: 30px; }
/* Upload zone */
.upload-zone {
border: 3px dashed #333;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
background: #111;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover, .upload-zone.drag-over {
border-color: #ff6b6b;
background: #1a1a1a;
}
.upload-zone input[type="file"] { display: none; }
.upload-icon { font-size: 48px; margin-bottom: 15px; }
.upload-text { color: #888; font-size: 16px; }
.upload-hint { color: #555; font-size: 13px; margin-top: 10px; }
.status {
margin-top: 30px;
padding: 20px;
background: #1a1a1a;
border-radius: 8px;
border: 1px solid #333;
}
.status-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; }
.status-row:last-child { border-bottom: none; }
.status-label { color: #888; }
.status-value { color: #e0e0e0; font-family: monospace; }
.progress { display: none; margin-top: 20px; }
.progress-bar {
height: 8px; background: #333; border-radius: 4px; overflow: hidden;
}
.progress-fill {
height: 100%; background: #ff6b6b; width: 0%; transition: width 0.3s;
}
.progress-text { text-align: center; margin-top: 10px; color: #888; font-size: 14px; }
.success { color: #6f6; }
.error { color: #f66; }
{% endblock %}
{% block content %}
<div class="container">
<h1>{{ t.import_mode }}</h1>
<p class="subtitle">{{ t.import_subtitle }}</p>
<div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
<div class="upload-icon">📦</div>
<div class="upload-text">{{ t.drop_or_browse }}</div>
<div class="upload-hint">{{ t.supported_formats }}</div>
<input type="file" id="file-input" accept=".tar.gz,.tgz,.run">
</div>
<div class="progress" id="progress">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="progress-text" id="progress-text">{{ t.uploading }}</div>
</div>
<div class="status" id="status">
<h3 style="margin-top:0;color:#ff6b6b;">{{ t.current_archive }}</h3>
<div id="status-content">{{ t.loading }}</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const T = {{ t_json|safe }};
const zone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const progress = document.getElementById('progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) uploadFile(fileInput.files[0]);
});
const CHUNK_SIZE = 10 * 1024 * 1024;
const MAX_RETRIES = 10;
const RETRY_DELAY = 2000;
let currentUpload = null;
async function uploadFile(file) {
progress.style.display = 'block';
progressFill.style.width = '0%';
progressText.textContent = `${T.initializing_upload} ${file.name}...`;
try {
const initRes = await fetch(`/api/import/upload/init?filename=${encodeURIComponent(file.name)}&size=${file.size}`, { method: 'POST' });
if (!initRes.ok) {
const err = await initRes.json();
throw new Error(err.detail || 'Failed to initialize upload');
}
const init = await initRes.json();
currentUpload = { uploadId: init.upload_id, file, offset: 0 };
await uploadChunks();
} catch (err) {
progressText.innerHTML = `<span class="error">✗ ${err.message}</span>`;
}
}
async function uploadChunks() {
const { uploadId, file } = currentUpload;
let retries = 0;
while (currentUpload.offset < file.size) {
const start = currentUpload.offset;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
try {
const res = await fetch(`/api/import/upload/${uploadId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/octet-stream',
'X-Upload-Offset': start.toString()
},
body: chunk
});
if (!res.ok) {
if (res.status === 409) {
const status = await getUploadStatus(uploadId);
currentUpload.offset = status.bytes_received;
continue;
}
const err = await res.json();
throw new Error(err.detail || 'Chunk upload failed');
}
const data = await res.json();
currentUpload.offset = data.bytes_received;
retries = 0;
const pct = (data.bytes_received / file.size * 100).toFixed(1);
progressFill.style.width = pct + '%';
const mb = (data.bytes_received / 1024 / 1024).toFixed(1);
const totalMb = (file.size / 1024 / 1024).toFixed(1);
progressText.textContent = `Uploading ${file.name}... ${pct}% (${mb}/${totalMb} MB)`;
} catch (err) {
retries++;
if (retries > MAX_RETRIES) {
progressText.innerHTML = `<span class="error">✗ ${T.upload_failed_retries}: ${err.message}</span>`;
return;
}
progressText.textContent = `${T.connection_lost_retry} (${retries}/${MAX_RETRIES})...`;
await new Promise(r => setTimeout(r, RETRY_DELAY));
try {
const status = await getUploadStatus(uploadId);
currentUpload.offset = status.bytes_received;
} catch (e) {}
}
}
try {
const completeRes = await fetch(`/api/import/upload/${uploadId}/complete`, { method: 'POST' });
if (!completeRes.ok) {
const err = await completeRes.json();
throw new Error(err.detail || 'Failed to complete upload');
}
const result = await completeRes.json();
progressText.innerHTML = `<span class="success">✓ ${T.archive_loaded} #${result.job_id}</span>`;
currentUpload = null;
loadStatus();
} catch (err) {
progressText.innerHTML = `<span class="error">✗ ${err.message}</span>`;
}
}
async function getUploadStatus(uploadId) {
const res = await fetch(`/api/import/upload/${uploadId}/status`);
if (!res.ok) throw new Error('Failed to get upload status');
return await res.json();
}
async function loadStatus() {
try {
const res = await fetch('/api/import/status');
const data = await res.json();
const content = document.getElementById('status-content');
content.innerHTML = `
<div class="status-row"><span class="status-label">${T.media_in_db}</span><span class="status-value">${(data.media_count || 0).toLocaleString()}</span></div>
<div class="status-row"><span class="status-label">${T.pages_indexed}</span><span class="status-value">${(data.pages_count || 0).toLocaleString()}</span></div>
<div class="status-row"><span class="status-label">${T.recent_imports}</span><span class="status-value">${data.recent_imports || 0}</span></div>
<div class="status-row"><a href="/crawl" style="color:#ff6b6b;">${T.view_all_jobs} →</a></div>
`;
} catch (err) {
document.getElementById('status-content').innerHTML = '<div class="error">' + T.failed_load_status + '</div>';
}
}
loadStatus();
</script>
{% endblock %}