189 lines
12 KiB
HTML
189 lines
12 KiB
HTML
<!doctype html>
|
|
<html lang="en" xml:lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Image Drop Uploader (Invite)</title>
|
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = { darkMode: 'class' };
|
|
</script>
|
|
<style>
|
|
body { transition: background-color .2s ease, color .2s ease; padding-bottom: env(safe-area-inset-bottom); }
|
|
</style>
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
<meta http-equiv="Cache-Control" content="no-store" />
|
|
<meta http-equiv="Pragma" content="no-cache" />
|
|
</head>
|
|
<body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
|
<div class="mx-auto max-w-4xl p-6 space-y-6">
|
|
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
|
|
|
<header class="flex items-center justify-between flex-wrap gap-2">
|
|
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
|
|
<div class="flex items-center gap-2">
|
|
<a id="linkHome" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Home</a>
|
|
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
|
|
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414z" clip-rule="evenodd"/>
|
|
</svg>
|
|
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
|
|
</svg>
|
|
</button>
|
|
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
|
|
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Link information -->
|
|
<section id="linkInfo" class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 text-sm">
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
|
<div>
|
|
<div><b>Status:</b> <span id="liStatus">Loading…</span></div>
|
|
<div><b>Album:</b> <span id="liAlbum">—</span></div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1">
|
|
<div><b>Type:</b> <span id="liType">—</span></div>
|
|
<div><b>Uses left:</b> <span id="liUses">—</span></div>
|
|
<div><b>Expires:</b> <span id="liExpires">—</span></div>
|
|
<div><b>Claimed:</b> <span id="liClaimed">—</span></div>
|
|
</div>
|
|
</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">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
|
</div>
|
|
<p class="mt-3 font-medium hidden md:block">Drop images or videos here</p>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
|
<div class="mt-3 relative inline-block">
|
|
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none" aria-label="Choose files">
|
|
Choose files
|
|
<input id="fileInput" type="file" multiple accept="image/*,video/*" class="absolute inset-0 opacity-0 cursor-pointer" />
|
|
</label>
|
|
</div>
|
|
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
|
Files will be uploaded to the selected album for this invite.
|
|
</div>
|
|
</section>
|
|
|
|
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
|
<div class="flex flex-wrap items-end justify-between gap-2 text-sm">
|
|
<!-- Buttons: ensure present on invite page and visible on small screens -->
|
|
<div class="order-1 w-full md:order-2 md:w-auto flex gap-2 justify-end">
|
|
<button id="btnClearFinished" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-700 hover:bg-gray-100 transition-colors">Clear finished</button>
|
|
<button id="btnClearAll" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-700 hover:bg-gray-100 transition-colors">Clear all</button>
|
|
</div>
|
|
<!-- Counters: wrap cleanly, keep number with its label and align bottoms -->
|
|
<div class="order-2 w-full md:order-1 md:w-auto flex flex-wrap items-end gap-x-4 gap-y-1">
|
|
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
|
|
<span class="whitespace-nowrap">Uploading: <b id="countUploading">0</b></span>
|
|
<span class="whitespace-nowrap">Done: <b id="countDone">0</b></span>
|
|
<span class="whitespace-nowrap">Duplicates: <b id="countDup">0</b></span>
|
|
<span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="items" class="space-y-3"></section>
|
|
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
|
|
Invite upload page
|
|
</footer>
|
|
</div>
|
|
<!-- Sticky mobile upload bar -->
|
|
<div class="md:hidden fixed left-0 right-0 bottom-0 z-20 p-3 bg-white/90 dark:bg-gray-900/90 border-t border-gray-200 dark:border-gray-700 backdrop-blur" style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)">
|
|
<div class="mx-auto max-w-4xl">
|
|
<button id="btnMobilePick" class="w-full rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3" aria-label="Choose files">Choose files</button>
|
|
</div>
|
|
</div>
|
|
<script src="/static/header.js"></script>
|
|
<script src="/static/app.js"></script>
|
|
</body>
|
|
<script>
|
|
// Hide ping and theme controls if referenced by app.js
|
|
// Standardized header includes ping + theme; no-op guards in app.js
|
|
// Populate link info section
|
|
(async function(){
|
|
try {
|
|
const parts = (location.pathname || '').split('/').filter(Boolean);
|
|
const token = parts[1];
|
|
const r = await fetch(`/api/invite/${token}`);
|
|
const j = await r.json();
|
|
const fmt = (s)=> s ? new Date(s).toLocaleString() : '—';
|
|
document.getElementById('liAlbum').textContent = j.albumName || j.albumId || '—';
|
|
document.getElementById('liType').textContent = j.oneTime ? 'One-time' : (j.maxUses < 0 ? 'Indefinite' : `Up to ${j.maxUses} uses`);
|
|
document.getElementById('liUses').textContent = (typeof j.remaining==='number') ? String(j.remaining) : '—';
|
|
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' + (j.inactiveReason ? (' ('+j.inactiveReason+')') : ''));
|
|
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
|
|
dz.classList.add('opacity-50');
|
|
fi.disabled = true;
|
|
itemsEl.innerHTML = `<div class="text-sm text-gray-500">This link is not active${j.inactiveReason?` (${j.inactiveReason})`:''}.</div>`;
|
|
}
|
|
} catch {}
|
|
})();
|
|
// Hook theme/ping
|
|
(function(){
|
|
const btnTheme = document.getElementById('btnTheme');
|
|
const btnPing = document.getElementById('btnPing');
|
|
const pingStatus = document.getElementById('pingStatus');
|
|
if (btnTheme) btnTheme.onclick = ()=>{ const isDark = document.documentElement.classList.toggle('dark'); try{ localStorage.setItem('theme', isDark ? 'dark':'light'); }catch{}; const isDarkNow = document.documentElement.classList.contains('dark'); try{ document.getElementById('iconLight').classList.toggle('hidden', !isDarkNow); document.getElementById('iconDark').classList.toggle('hidden', isDarkNow);}catch{} };
|
|
if (btnPing) btnPing.onclick = async ()=>{
|
|
pingStatus.textContent = 'checking…';
|
|
try { const r = await fetch('/api/ping', { method:'POST' }); const j = await r.json(); pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); if(j.ok){ let text = `Connected to server.`; if(j.album_name) text += ` | Uploading to album: "${j.album_name}"`; showBanner(text, 'ok'); } } catch { pingStatus.textContent = 'No connection'; pingStatus.className='ml-2 text-sm text-red-600'; }
|
|
};
|
|
})();
|
|
</script>
|
|
</html>
|