444 lines
13 KiB
HTML
444 lines
13 KiB
HTML
{% extends "portal/shared_space/base.html" %}
|
|
|
|
{% block title %}Upload - {{ space.name }}{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.upload-zone {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 12px;
|
|
padding: 4rem 2rem;
|
|
text-align: center;
|
|
transition: all 0.3s;
|
|
cursor: pointer;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.upload-zone:hover, .upload-zone.dragover {
|
|
border-color: var(--primary);
|
|
background: rgba(139, 92, 246, 0.05);
|
|
}
|
|
|
|
.upload-zone.uploading {
|
|
pointer-events: none;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.upload-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.upload-zone h2 {
|
|
font-size: 1.25rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.upload-zone p {
|
|
color: var(--text-muted);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.upload-zone input[type="file"] {
|
|
display: none;
|
|
}
|
|
|
|
.results {
|
|
display: grid;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.result-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.result-item.success {
|
|
border-color: var(--success);
|
|
}
|
|
|
|
.result-item.error {
|
|
border-color: var(--error);
|
|
}
|
|
|
|
.result-item.duplicate {
|
|
border-color: #f59e0b;
|
|
}
|
|
|
|
.duplicate-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.duplicate-actions button {
|
|
border: none;
|
|
padding: 0.35rem 0.85rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.btn-overwrite {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-overwrite:hover {
|
|
background: var(--primary-hover);
|
|
}
|
|
|
|
.btn-skip {
|
|
background: var(--border);
|
|
color: var(--text);
|
|
}
|
|
|
|
.btn-skip:hover {
|
|
background: var(--text-muted);
|
|
color: white;
|
|
}
|
|
|
|
.result-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.result-info h3 {
|
|
font-size: 1rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.result-info .meta {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.share-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: var(--bg);
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
font-family: monospace;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.share-link input {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text);
|
|
width: 300px;
|
|
outline: none;
|
|
}
|
|
|
|
.copy-btn {
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.copy-btn:hover {
|
|
background: var(--primary-hover);
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 4px;
|
|
background: var(--border);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.progress-bar .progress {
|
|
height: 100%;
|
|
background: var(--primary);
|
|
transition: width 0.3s;
|
|
}
|
|
|
|
.recent-section h2 {
|
|
font-size: 1rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.recent-files {
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.recent-file {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
text-decoration: none;
|
|
color: var(--text);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.recent-file:hover {
|
|
background: var(--surface-hover);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.recent-file .name {
|
|
font-size: 0.875rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.recent-file .size {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.share-link input {
|
|
width: 150px;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<h2 style="text-align: center; margin-bottom: 1.5rem; font-weight: 500;">Upload files to <span style="color: var(--primary);">{{ space.slug }}</span></h2>
|
|
|
|
<div class="upload-zone" id="uploadZone">
|
|
<div class="upload-icon">+</div>
|
|
<p>Drop files here or click to select</p>
|
|
<p class="mt-1 text-muted">{% if space.max_file_size_mb > 0 %}Max file size: {{ space.max_file_size_mb }}MB{% else %}No file size limit{% endif %}</p>
|
|
<input type="file" id="fileInput" multiple>
|
|
</div>
|
|
|
|
<div class="results" id="results"></div>
|
|
|
|
{% if recent_files %}
|
|
<div class="recent-section mt-2">
|
|
<h2>Recent Uploads</h2>
|
|
<div class="recent-files">
|
|
{% for file in recent_files %}
|
|
<div class="recent-file">
|
|
<span class="name">{{ file.title|default:file.original_filename }}</span>
|
|
<span class="size">{{ file.file_size|filesizeformat }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
const uploadZone = document.getElementById('uploadZone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const results = document.getElementById('results');
|
|
const maxSize = {{ space.max_file_size_mb }} * 1024 * 1024; // 0 = unlimited
|
|
const CHUNK_SIZE = 80 * 1024 * 1024; // 80MB chunks (under Cloudflare's 100MB limit)
|
|
const CHUNK_THRESHOLD = 90 * 1024 * 1024; // Use chunked upload for files > 90MB
|
|
|
|
uploadZone.addEventListener('click', () => fileInput.click());
|
|
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
|
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); });
|
|
uploadZone.addEventListener('drop', (e) => { e.preventDefault(); uploadZone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
|
|
fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
|
|
|
|
function handleFiles(files) { Array.from(files).forEach(f => uploadFile(f)); }
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function makeItem(file, action) {
|
|
let item = action ? document.getElementById('dup-' + file.name) : null;
|
|
if (!item) {
|
|
item = document.createElement('div');
|
|
item.className = 'result-item';
|
|
results.insertBefore(item, results.firstChild);
|
|
} else {
|
|
item.className = 'result-item';
|
|
}
|
|
item.innerHTML = `
|
|
<div class="result-info">
|
|
<h3>${file.name}</h3>
|
|
<div class="meta">${formatBytes(file.size)} - Uploading...</div>
|
|
<div class="progress-bar"><div class="progress" style="width: 0%"></div></div>
|
|
</div>
|
|
`;
|
|
return item;
|
|
}
|
|
|
|
function showSuccess(item, data) {
|
|
item.classList.add('success');
|
|
item.innerHTML = `
|
|
<div class="result-info">
|
|
<h3>${data.file.title}</h3>
|
|
<div class="meta">${formatBytes(data.file.size)} - Uploaded successfully</div>
|
|
</div>
|
|
<div class="share-link">
|
|
<input type="text" value="${data.share.url}" readonly onclick="this.select()">
|
|
<button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function showDuplicate(item, file, data, action) {
|
|
item.id = 'dup-' + file.name;
|
|
item.classList.add('duplicate');
|
|
const existingSize = data.existing_file.size ? formatBytes(data.existing_file.size) : 'unknown size';
|
|
item.innerHTML = `
|
|
<div class="result-info">
|
|
<h3>${file.name}</h3>
|
|
<div class="meta">"${data.existing_file.title}" already exists (${existingSize})</div>
|
|
</div>
|
|
<div class="duplicate-actions">
|
|
<button class="btn-overwrite">Overwrite</button>
|
|
<button class="btn-skip">Skip</button>
|
|
</div>
|
|
`;
|
|
item.querySelector('.btn-overwrite').addEventListener('click', () => uploadFile(file, 'overwrite'));
|
|
item.querySelector('.btn-skip').addEventListener('click', () => {
|
|
item.classList.remove('duplicate');
|
|
item.classList.add('error');
|
|
item.innerHTML = `<div class="result-info"><h3>${file.name}</h3><div class="meta">Skipped (duplicate)</div></div>`;
|
|
});
|
|
}
|
|
|
|
function showError(item, msg) {
|
|
item.classList.add('error');
|
|
item.innerHTML = `<div class="result-info"><h3>${item.querySelector('h3')?.textContent || 'File'}</h3><div class="meta">${msg}</div></div>`;
|
|
}
|
|
|
|
function uploadFile(file, action) {
|
|
if (maxSize > 0 && file.size > maxSize) {
|
|
const item = document.createElement('div');
|
|
item.className = 'result-item error';
|
|
item.innerHTML = `<div class="result-info"><h3>${file.name}</h3><div class="meta">File too large (${formatBytes(file.size)}). Max: {{ space.max_file_size_mb }}MB</div></div>`;
|
|
results.insertBefore(item, results.firstChild);
|
|
return;
|
|
}
|
|
|
|
if (file.size > CHUNK_THRESHOLD) {
|
|
uploadFileChunked(file, action);
|
|
} else {
|
|
uploadFileSimple(file, action);
|
|
}
|
|
}
|
|
|
|
// Simple upload for files < 90MB
|
|
function uploadFileSimple(file, action) {
|
|
const item = makeItem(file, action);
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
if (action) formData.append('action', action);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) item.querySelector('.progress').style.width = (e.loaded / e.total * 100) + '%';
|
|
});
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status === 200) {
|
|
showSuccess(item, JSON.parse(xhr.responseText));
|
|
} else if (xhr.status === 409) {
|
|
showDuplicate(item, file, JSON.parse(xhr.responseText));
|
|
} else {
|
|
let msg = 'Upload failed';
|
|
try { msg = JSON.parse(xhr.responseText).error || msg; } catch(e) {}
|
|
showError(item, msg);
|
|
}
|
|
});
|
|
xhr.addEventListener('error', () => showError(item, 'Upload failed - network error'));
|
|
xhr.open('POST', '{% url "shared_space_upload" %}');
|
|
xhr.send(formData);
|
|
}
|
|
|
|
// Chunked upload for files >= 90MB
|
|
async function uploadFileChunked(file, action) {
|
|
const item = makeItem(file, action);
|
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
|
|
|
try {
|
|
// Step 1: Initialize upload
|
|
const initForm = new FormData();
|
|
initForm.append('filename', file.name);
|
|
initForm.append('total_size', file.size);
|
|
initForm.append('total_chunks', totalChunks);
|
|
initForm.append('mime_type', file.type || 'application/octet-stream');
|
|
if (action) initForm.append('action', action);
|
|
|
|
const initResp = await fetch('{% url "chunked_upload_init" %}', { method: 'POST', body: initForm });
|
|
const initData = await initResp.json();
|
|
|
|
if (initResp.status === 409) {
|
|
showDuplicate(item, file, initData);
|
|
return;
|
|
}
|
|
if (!initResp.ok) {
|
|
showError(item, initData.error || 'Failed to start upload');
|
|
return;
|
|
}
|
|
|
|
const uploadId = initData.upload_id;
|
|
|
|
// Step 2: Send chunks sequentially
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const start = i * CHUNK_SIZE;
|
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
|
const chunk = file.slice(start, end);
|
|
|
|
const chunkForm = new FormData();
|
|
chunkForm.append('upload_id', uploadId);
|
|
chunkForm.append('chunk_index', i);
|
|
chunkForm.append('chunk', chunk, `chunk_${i}`);
|
|
|
|
const chunkResp = await fetch('{% url "chunked_upload_chunk" %}', { method: 'POST', body: chunkForm });
|
|
const chunkData = await chunkResp.json();
|
|
|
|
if (!chunkResp.ok) {
|
|
showError(item, chunkData.error || 'Chunk upload failed');
|
|
return;
|
|
}
|
|
|
|
// Update progress
|
|
const percent = ((i + 1) / totalChunks) * 100;
|
|
item.querySelector('.progress').style.width = percent + '%';
|
|
item.querySelector('.meta').textContent = `${formatBytes(file.size)} - Uploading chunk ${i + 1}/${totalChunks}...`;
|
|
|
|
// If this was the last chunk, the response includes the final file data
|
|
if (chunkData.success) {
|
|
showSuccess(item, chunkData);
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
showError(item, 'Upload failed - ' + err.message);
|
|
}
|
|
}
|
|
|
|
function copyLink(btn, url) {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
const orig = btn.textContent;
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => btn.textContent = orig, 2000);
|
|
});
|
|
}
|
|
</script>
|
|
{% endblock %}
|