rfiles-online/portal/templates/portal/shared_space/home.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 %}