219 lines
7.9 KiB
Django/Jinja
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 %}
|