chunks enabled.
This commit is contained in:
129
frontend/app.js
129
frontend/app.js
@@ -1,5 +1,6 @@
|
||||
// Frontend logic (mobile-safe picker; no settings UI)
|
||||
const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
|
||||
let CFG = { chunked_uploads_enabled: false, chunk_size_mb: 95 };
|
||||
// Detect invite token from URL path /invite/{token}
|
||||
let INVITE_TOKEN = null;
|
||||
try {
|
||||
@@ -40,6 +41,21 @@ function updateThemeIcon() {
|
||||
|
||||
initDarkMode();
|
||||
|
||||
// --- Load minimal config ---
|
||||
(async function loadConfig(){
|
||||
try{
|
||||
const r = await fetch('/api/config');
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j && typeof j === 'object') {
|
||||
CFG.chunked_uploads_enabled = !!j.chunked_uploads_enabled;
|
||||
const n = parseInt(j.chunk_size_mb, 10);
|
||||
if (!Number.isNaN(n) && n > 0) CFG.chunk_size_mb = n;
|
||||
}
|
||||
}
|
||||
}catch{}
|
||||
})();
|
||||
|
||||
// --- helpers ---
|
||||
function human(bytes){
|
||||
if (!bytes) return '0 B';
|
||||
@@ -156,27 +172,10 @@ async function runQueue(){
|
||||
render();
|
||||
inflight++;
|
||||
try{
|
||||
const form = new FormData();
|
||||
form.append('file', next.file);
|
||||
form.append('item_id', next.id);
|
||||
form.append('session_id', sessionId);
|
||||
form.append('last_modified', next.file.lastModified || '');
|
||||
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
|
||||
const res = await fetch('/api/upload', { method:'POST', body: form });
|
||||
const body = await res.json().catch(()=>({}));
|
||||
if(!res.ok && next.status!=='error'){
|
||||
next.status='error';
|
||||
next.message = body.error || 'Upload failed';
|
||||
render();
|
||||
} else if (res.ok) {
|
||||
// Fallback finalize on HTTP success in case WS final message is missed
|
||||
const statusText = (body && body.status) ? String(body.status) : '';
|
||||
const isDuplicate = /duplicate/i.test(statusText);
|
||||
next.status = isDuplicate ? 'duplicate' : 'done';
|
||||
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
|
||||
next.progress = 100;
|
||||
render();
|
||||
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {}
|
||||
if (CFG.chunked_uploads_enabled && next.file.size > (CFG.chunk_size_mb * 1024 * 1024)) {
|
||||
await uploadChunked(next);
|
||||
} else {
|
||||
await uploadWhole(next);
|
||||
}
|
||||
}catch(err){
|
||||
next.status='error';
|
||||
@@ -190,6 +189,94 @@ async function runQueue(){
|
||||
for(let i=0;i<3;i++) runNext();
|
||||
}
|
||||
|
||||
async function uploadWhole(next){
|
||||
const form = new FormData();
|
||||
form.append('file', next.file);
|
||||
form.append('item_id', next.id);
|
||||
form.append('session_id', sessionId);
|
||||
form.append('last_modified', next.file.lastModified || '');
|
||||
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
|
||||
const res = await fetch('/api/upload', { method:'POST', body: form });
|
||||
const body = await res.json().catch(()=>({}));
|
||||
if(!res.ok && next.status!=='error'){
|
||||
next.status='error';
|
||||
next.message = body.error || 'Upload failed';
|
||||
render();
|
||||
} else if (res.ok) {
|
||||
const statusText = (body && body.status) ? String(body.status) : '';
|
||||
const isDuplicate = /duplicate/i.test(statusText);
|
||||
next.status = isDuplicate ? 'duplicate' : 'done';
|
||||
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
|
||||
next.progress = 100;
|
||||
render();
|
||||
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadChunked(next){
|
||||
const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024;
|
||||
const total = Math.ceil(next.file.size / chunkBytes) || 1;
|
||||
// init
|
||||
try {
|
||||
await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
|
||||
item_id: next.id,
|
||||
session_id: sessionId,
|
||||
name: next.file.name,
|
||||
size: next.file.size,
|
||||
last_modified: next.file.lastModified || '',
|
||||
invite_token: INVITE_TOKEN || '',
|
||||
content_type: next.file.type || 'application/octet-stream'
|
||||
}) });
|
||||
} catch {}
|
||||
// upload parts
|
||||
let uploaded = 0;
|
||||
for (let i=0;i<total;i++){
|
||||
const start = i * chunkBytes;
|
||||
const end = Math.min(next.file.size, start + chunkBytes);
|
||||
const blob = next.file.slice(start, end);
|
||||
const fd = new FormData();
|
||||
fd.append('item_id', next.id);
|
||||
fd.append('session_id', sessionId);
|
||||
fd.append('chunk_index', String(i));
|
||||
fd.append('total_chunks', String(total));
|
||||
if (INVITE_TOKEN) fd.append('invite_token', INVITE_TOKEN);
|
||||
fd.append('chunk', blob, `${next.file.name}.part${i}`);
|
||||
const r = await fetch('/api/upload/chunk', { method:'POST', body: fd });
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(()=>({}));
|
||||
throw new Error(j.error || `Chunk ${i} failed`);
|
||||
}
|
||||
uploaded++;
|
||||
// Approximate progress until final server-side upload takes over
|
||||
next.status = 'uploading';
|
||||
next.progress = Math.min(90, Math.floor((uploaded/total) * 60) + 20); // stay under 100 until WS finish
|
||||
render();
|
||||
}
|
||||
// complete
|
||||
const rc = await fetch('/api/upload/chunk/complete', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
|
||||
item_id: next.id,
|
||||
session_id: sessionId,
|
||||
name: next.file.name,
|
||||
last_modified: next.file.lastModified || '',
|
||||
invite_token: INVITE_TOKEN || '',
|
||||
content_type: next.file.type || 'application/octet-stream',
|
||||
total_chunks: total
|
||||
}) });
|
||||
const body = await rc.json().catch(()=>({}));
|
||||
if (!rc.ok && next.status!=='error'){
|
||||
next.status='error';
|
||||
next.message = body.error || 'Upload failed';
|
||||
render();
|
||||
} else if (rc.ok) {
|
||||
const statusText = (body && body.status) ? String(body.status) : '';
|
||||
const isDuplicate = /duplicate/i.test(statusText);
|
||||
next.status = isDuplicate ? 'duplicate' : 'done';
|
||||
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
|
||||
next.progress = 100;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM refs ---
|
||||
const dz = document.getElementById('dropzone');
|
||||
const fi = document.getElementById('fileInput');
|
||||
|
||||
@@ -53,6 +53,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password gate (hidden unless required) -->
|
||||
<section id="pwGate" class="hidden rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
|
||||
<div class="text-sm">This link is protected. Enter the password to continue.</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<input id="pwInput" type="password" placeholder="Password" class="flex-1 rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
|
||||
<button id="btnPw" class="rounded-xl bg-black text-white px-4 py-3 dark:bg-white dark:text-black">Unlock</button>
|
||||
</div>
|
||||
<div id="pwError" class="hidden text-sm text-red-600 dark:text-red-400"></div>
|
||||
</section>
|
||||
|
||||
<!-- Dropzone and queue copied from index.html -->
|
||||
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-8 md:p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600">
|
||||
<div id="dropHint" class="mx-auto h-12 w-12 opacity-70 hidden md:block">
|
||||
@@ -120,14 +130,46 @@
|
||||
document.getElementById('liExpires').textContent = j.expiresAt ? fmt(j.expiresAt) : 'No expiry';
|
||||
document.getElementById('liClaimed').textContent = j.claimed ? 'Yes' : 'No';
|
||||
document.getElementById('liStatus').textContent = j.active ? 'Active' : 'Inactive';
|
||||
const dz = document.getElementById('dropzone');
|
||||
const fi = document.getElementById('fileInput');
|
||||
const itemsEl = document.getElementById('items');
|
||||
const pwGate = document.getElementById('pwGate');
|
||||
if (j.passwordRequired && !j.authorized) {
|
||||
// Show password gate and disable uploader until authorized
|
||||
pwGate.classList.remove('hidden');
|
||||
dz.classList.add('opacity-50');
|
||||
if (fi) fi.disabled = true;
|
||||
itemsEl.innerHTML = '<div class="text-sm text-gray-500">Enter the password above to enable uploads.</div>';
|
||||
// Wire unlock button
|
||||
const pwInput = document.getElementById('pwInput');
|
||||
const btnPw = document.getElementById('btnPw');
|
||||
const pwError = document.getElementById('pwError');
|
||||
const doAuth = async () => {
|
||||
pwError.classList.add('hidden');
|
||||
const pw = (pwInput && pwInput.value) ? pwInput.value.trim() : '';
|
||||
if (!pw) { pwError.textContent = 'Please enter a password.'; pwError.classList.remove('hidden'); return; }
|
||||
try {
|
||||
const rr = await fetch(`/api/invite/${token}/auth`, { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ password: pw }) });
|
||||
const jj = await rr.json().catch(()=>({}));
|
||||
if (!rr.ok || !jj.authorized) { pwError.textContent = 'Invalid password.'; pwError.classList.remove('hidden'); return; }
|
||||
pwGate.classList.add('hidden');
|
||||
dz.classList.remove('opacity-50');
|
||||
if (fi) fi.disabled = false;
|
||||
itemsEl.innerHTML = '';
|
||||
try { showBanner('Password accepted. You can upload now.', 'ok'); } catch {}
|
||||
} catch (e) {
|
||||
pwError.textContent = 'Error verifying password.';
|
||||
pwError.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
if (btnPw) btnPw.onclick = doAuth;
|
||||
if (pwInput) pwInput.addEventListener('keydown', (e)=>{ if (e.key==='Enter') { e.preventDefault(); doAuth(); } });
|
||||
}
|
||||
if (!j.active) {
|
||||
// Disable dropzone
|
||||
const dz = document.getElementById('dropzone');
|
||||
const fi = document.getElementById('fileInput');
|
||||
dz.classList.add('opacity-50');
|
||||
fi.disabled = true;
|
||||
const items = document.getElementById('items');
|
||||
items.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
|
||||
itemsEl.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
<div class="text-sm font-medium mb-1">Expires in days</div>
|
||||
<input id="days" type="number" min="0" placeholder="Leave empty for no expiry" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium mb-1">Password (optional)</div>
|
||||
<input id="password" type="password" placeholder="Set a password for this link" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
|
||||
<div class="mt-1 text-xs text-gray-500">Recipients must enter this password before uploading.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button id="btnCreate" class="w-full sm:w-auto rounded-xl bg-black text-white px-5 py-3 dark:bg-white dark:text-black">Create link</button>
|
||||
@@ -96,6 +101,7 @@
|
||||
const linkOut = document.getElementById('linkOut');
|
||||
const btnCopy = document.getElementById('btnCopy');
|
||||
const qrImg = document.getElementById('qrImg');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
function showResult(kind, text){
|
||||
result.className = 'rounded-xl border p-3 text-sm space-y-2 ' + (kind==='ok' ? 'border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300');
|
||||
@@ -145,6 +151,8 @@
|
||||
const d = days.value.trim();
|
||||
if (d) payload.expiresDays = parseInt(d, 10);
|
||||
if (albumId) payload.albumId = albumId; else if (albumName) payload.albumName = albumName;
|
||||
const pw = (passwordInput && passwordInput.value) ? passwordInput.value.trim() : '';
|
||||
if (pw) payload.password = pw;
|
||||
try{
|
||||
const r = await fetch('/api/invites', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify(payload) });
|
||||
const j = await r.json().catch(()=>({}));
|
||||
|
||||
Reference in New Issue
Block a user